@rushinglabs/taskfile 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026, Darrien Rushing
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # Taskfile
2
+
3
+ A simple todo list CLI, runnable via `npx`.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npx @rushinglabs/taskfile add "Buy milk"
9
+ npx @rushinglabs/taskfile list
10
+ npx @rushinglabs/taskfile remove 1
11
+ ```
12
+
13
+ ## Development
14
+
15
+ ```bash
16
+ npm install
17
+ ```
18
+
19
+ ### Running commands against the dev instance
20
+
21
+ There are a few ways to run the CLI locally while developing, without publishing anything.
22
+
23
+ **Option 1: `ts-node` (no build step)**
24
+
25
+ Runs the TypeScript source directly — fastest way to iterate:
26
+
27
+ ```bash
28
+ npm run dev -- add "Buy milk"
29
+ npm run dev -- list
30
+ npm run dev -- remove 1
31
+ ```
32
+
33
+ This starts the TUI via the development build.
34
+ ```bash
35
+ npm run dev --
36
+ ```
37
+
38
+ **Option 2: build once, run compiled output**
39
+
40
+ Matches what actually ships:
41
+
42
+ ```bash
43
+ npm run build
44
+ node dist/index.js add "Buy milk"
45
+ node dist/index.js list
46
+ node dist/index.js remove 1
47
+ ```
48
+
49
+ **Option 3: `npm link` (test the real `taskfile` command)**
50
+
51
+ Simulates installing the package globally, so you can run it as `taskfile` from anywhere, the same way `npx` would invoke it:
52
+
53
+ ```bash
54
+ npm run build
55
+ npm link
56
+ taskfile add "Buy milk"
57
+ taskfile list
58
+ ```
59
+
60
+ When you're done, remove the link with:
61
+
62
+ ```bash
63
+ npm unlink -g @rushinglabs/taskfile
64
+ ```
65
+
66
+ Tasks are stored at `~/.taskfile/tasks.json` regardless of which option you use.
67
+
68
+ ### Running tests
69
+
70
+ ```bash
71
+ npm test
72
+ ```
@@ -0,0 +1,5 @@
1
+ import { addTask, getDefaultListId } from "../store.js";
2
+ export function addItem(text) {
3
+ const task = addTask(getDefaultListId(), text);
4
+ console.log(`Added task #${task.id}: ${task.text}`);
5
+ }
@@ -0,0 +1,12 @@
1
+ import { getDefaultListId, loadTasks } from "../store.js";
2
+ export function listItems() {
3
+ const tasks = loadTasks(getDefaultListId());
4
+ if (tasks.length === 0) {
5
+ console.log("No tasks yet. Add one with `taskfile add <text>`.");
6
+ return;
7
+ }
8
+ for (const task of tasks) {
9
+ const marker = task.done ? "[x]" : "[ ]";
10
+ console.log(`${marker} #${task.id} ${task.text}`);
11
+ }
12
+ }
@@ -0,0 +1,15 @@
1
+ import { removeTask } from "../store.js";
2
+ export function removeItem(id) {
3
+ const result = removeTask(id);
4
+ if (result.status === "not-found") {
5
+ console.error(`No task found with id #${id}`);
6
+ process.exitCode = 1;
7
+ return;
8
+ }
9
+ if (result.status === "blocked") {
10
+ console.error(result.reason);
11
+ process.exitCode = 1;
12
+ return;
13
+ }
14
+ console.log(`Removed task #${result.task.id}: ${result.task.text}`);
15
+ }
package/dist/index.js ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ process.removeAllListeners("warning");
3
+ process.on("warning", (warning) => {
4
+ if (warning.name === "ExperimentalWarning" && /SQLite/i.test(warning.message)) {
5
+ return;
6
+ }
7
+ console.warn(warning);
8
+ });
9
+ import { readFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { Command } from "commander";
13
+ import { addItem } from "./commands/add.js";
14
+ import { removeItem } from "./commands/remove.js";
15
+ import { listItems } from "./commands/list.js";
16
+ import { runTui } from "./tui/index.js";
17
+ const packageDir = dirname(fileURLToPath(import.meta.url));
18
+ const { version } = JSON.parse(readFileSync(join(packageDir, "../package.json"), "utf8"));
19
+ const program = new Command();
20
+ program.name("taskfile").description("A simple todo list CLI").version(version);
21
+ program
22
+ .command("add <text...>")
23
+ .description("Add a new task")
24
+ .action((text) => {
25
+ addItem(text.join(" "));
26
+ });
27
+ program
28
+ .command("remove <id>")
29
+ .description("Remove a task by id")
30
+ .action((id) => {
31
+ removeItem(Number(id));
32
+ });
33
+ program
34
+ .command("list")
35
+ .description("List all tasks")
36
+ .action(() => {
37
+ listItems();
38
+ });
39
+ program
40
+ .command("tui", { isDefault: true, hidden: true })
41
+ .description("Launch the interactive TUI")
42
+ .action(async () => {
43
+ if (!process.stdin.isTTY) {
44
+ program.help();
45
+ return;
46
+ }
47
+ await runTui();
48
+ });
49
+ await program.parseAsync();
@@ -0,0 +1,74 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ export const SNAPSHOT_RETENTION_OPTIONS = [
5
+ "1-week",
6
+ "1-month",
7
+ "3-months",
8
+ "6-months",
9
+ "12-months",
10
+ "forever",
11
+ ];
12
+ const DEFAULT_SETTINGS = {
13
+ width: 120,
14
+ height: 40,
15
+ progressAnimation: true,
16
+ lastActiveListId: null,
17
+ dailySnapshotsEnabled: true,
18
+ dailySnapshotRetention: "12-months",
19
+ };
20
+ const WIDTH_RANGE = { min: 40, max: 300 };
21
+ const HEIGHT_RANGE = { min: 10, max: 100 };
22
+ function isValidRetention(value) {
23
+ return typeof value === "string" && SNAPSHOT_RETENTION_OPTIONS.includes(value);
24
+ }
25
+ function clamp(value, range) {
26
+ return Math.min(Math.max(Math.round(value), range.min), range.max);
27
+ }
28
+ function getStoreDir() {
29
+ return path.join(os.homedir(), ".taskfile");
30
+ }
31
+ function getSettingsFile() {
32
+ return path.join(getStoreDir(), "settings.json");
33
+ }
34
+ export function loadSettings() {
35
+ const settingsFile = getSettingsFile();
36
+ if (!fs.existsSync(settingsFile)) {
37
+ return { ...DEFAULT_SETTINGS };
38
+ }
39
+ try {
40
+ const parsed = JSON.parse(fs.readFileSync(settingsFile, "utf-8"));
41
+ return {
42
+ width: typeof parsed.width === "number" ? clamp(parsed.width, WIDTH_RANGE) : DEFAULT_SETTINGS.width,
43
+ height: typeof parsed.height === "number" ? clamp(parsed.height, HEIGHT_RANGE) : DEFAULT_SETTINGS.height,
44
+ progressAnimation: typeof parsed.progressAnimation === "boolean" ? parsed.progressAnimation : DEFAULT_SETTINGS.progressAnimation,
45
+ lastActiveListId: typeof parsed.lastActiveListId === "number" ? parsed.lastActiveListId : DEFAULT_SETTINGS.lastActiveListId,
46
+ dailySnapshotsEnabled: typeof parsed.dailySnapshotsEnabled === "boolean"
47
+ ? parsed.dailySnapshotsEnabled
48
+ : DEFAULT_SETTINGS.dailySnapshotsEnabled,
49
+ dailySnapshotRetention: isValidRetention(parsed.dailySnapshotRetention)
50
+ ? parsed.dailySnapshotRetention
51
+ : DEFAULT_SETTINGS.dailySnapshotRetention,
52
+ };
53
+ }
54
+ catch {
55
+ return { ...DEFAULT_SETTINGS };
56
+ }
57
+ }
58
+ export function saveSettings(update) {
59
+ const current = loadSettings();
60
+ const next = {
61
+ width: update.width !== undefined ? clamp(update.width, WIDTH_RANGE) : current.width,
62
+ height: update.height !== undefined ? clamp(update.height, HEIGHT_RANGE) : current.height,
63
+ progressAnimation: update.progressAnimation !== undefined ? update.progressAnimation : current.progressAnimation,
64
+ lastActiveListId: update.lastActiveListId !== undefined ? update.lastActiveListId : current.lastActiveListId,
65
+ dailySnapshotsEnabled: update.dailySnapshotsEnabled !== undefined ? update.dailySnapshotsEnabled : current.dailySnapshotsEnabled,
66
+ dailySnapshotRetention: update.dailySnapshotRetention !== undefined ? update.dailySnapshotRetention : current.dailySnapshotRetention,
67
+ };
68
+ const storeDir = getStoreDir();
69
+ if (!fs.existsSync(storeDir)) {
70
+ fs.mkdirSync(storeDir, { recursive: true });
71
+ }
72
+ fs.writeFileSync(getSettingsFile(), JSON.stringify(next, null, 2));
73
+ return next;
74
+ }
@@ -0,0 +1,97 @@
1
+ import { getDb, loadLists, loadTasks } from "./store.js";
2
+ const RETENTION_DAYS = {
3
+ "1-week": 7,
4
+ "1-month": 30,
5
+ "3-months": 90,
6
+ "6-months": 180,
7
+ "12-months": 365,
8
+ };
9
+ function localDateString(date) {
10
+ const year = date.getFullYear();
11
+ const month = String(date.getMonth() + 1).padStart(2, "0");
12
+ const day = String(date.getDate()).padStart(2, "0");
13
+ return `${year}-${month}-${day}`;
14
+ }
15
+ export function captureSnapshot(date) {
16
+ const db = getDb();
17
+ const createdAt = new Date().toISOString();
18
+ const snapshotInfo = db.prepare("INSERT INTO snapshots (date, createdAt) VALUES (?, ?)").run(date, createdAt);
19
+ const snapshotId = Number(snapshotInfo.lastInsertRowid);
20
+ const lists = loadLists();
21
+ lists.forEach((list, listOrderIndex) => {
22
+ const listInfo = db
23
+ .prepare("INSERT INTO snapshot_lists (snapshotId, name, createdAt, orderIndex) VALUES (?, ?, ?, ?)")
24
+ .run(snapshotId, list.name, list.createdAt, listOrderIndex);
25
+ const snapshotListId = Number(listInfo.lastInsertRowid);
26
+ const originalToSnapshotTaskId = new Map();
27
+ loadTasks(list.id).forEach((task, taskOrderIndex) => {
28
+ const parentSnapshotTaskId = task.parentId === null ? null : (originalToSnapshotTaskId.get(task.parentId) ?? null);
29
+ const taskInfo = db
30
+ .prepare(`INSERT INTO snapshot_tasks
31
+ (snapshotListId, text, done, priority, parentSnapshotTaskId, createdAt, orderIndex)
32
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
33
+ .run(snapshotListId, task.text, task.done ? 1 : 0, task.priority ? 1 : 0, parentSnapshotTaskId, task.createdAt, taskOrderIndex);
34
+ originalToSnapshotTaskId.set(task.id, Number(taskInfo.lastInsertRowid));
35
+ });
36
+ });
37
+ }
38
+ export function captureSnapshotIfNeeded(settings) {
39
+ if (!settings.dailySnapshotsEnabled)
40
+ return;
41
+ const today = localDateString(new Date());
42
+ const existing = getDb().prepare("SELECT id FROM snapshots WHERE date = ?").get(today);
43
+ if (existing)
44
+ return;
45
+ captureSnapshot(today);
46
+ }
47
+ export function pruneSnapshots(retention) {
48
+ if (retention === "forever")
49
+ return;
50
+ const cutoff = localDateString(new Date(Date.now() - RETENTION_DAYS[retention] * 24 * 60 * 60 * 1000));
51
+ const db = getDb();
52
+ db.prepare(`DELETE FROM snapshot_tasks WHERE snapshotListId IN (
53
+ SELECT id FROM snapshot_lists WHERE snapshotId IN (SELECT id FROM snapshots WHERE date < ?)
54
+ )`).run(cutoff);
55
+ db.prepare("DELETE FROM snapshot_lists WHERE snapshotId IN (SELECT id FROM snapshots WHERE date < ?)").run(cutoff);
56
+ db.prepare("DELETE FROM snapshots WHERE date < ?").run(cutoff);
57
+ }
58
+ export function runStartupSnapshotMaintenance(settings) {
59
+ pruneSnapshots(settings.dailySnapshotRetention);
60
+ captureSnapshotIfNeeded(settings);
61
+ }
62
+ export function listSnapshots() {
63
+ const rows = getDb().prepare("SELECT id, date FROM snapshots ORDER BY date DESC").all();
64
+ return rows.map((row) => ({ id: row.id, date: row.date }));
65
+ }
66
+ export function loadSnapshotDetail(id) {
67
+ const db = getDb();
68
+ const snapshot = db.prepare("SELECT id, date FROM snapshots WHERE id = ?").get(id);
69
+ if (!snapshot)
70
+ return undefined;
71
+ const listRows = db
72
+ .prepare("SELECT id, name FROM snapshot_lists WHERE snapshotId = ? ORDER BY orderIndex")
73
+ .all(id);
74
+ const lists = listRows.map((listRow) => {
75
+ const taskRows = db
76
+ .prepare(`SELECT id, snapshotListId, text, done, priority, parentSnapshotTaskId, createdAt
77
+ FROM snapshot_tasks WHERE snapshotListId = ? ORDER BY orderIndex`)
78
+ .all(listRow.id);
79
+ const tasks = taskRows.map((row) => ({
80
+ id: row.id,
81
+ text: row.text,
82
+ done: !!row.done,
83
+ parentId: row.parentSnapshotTaskId,
84
+ priority: !!row.priority,
85
+ listId: row.snapshotListId,
86
+ createdAt: row.createdAt,
87
+ }));
88
+ return { name: listRow.name, tasks };
89
+ });
90
+ return { id: snapshot.id, date: snapshot.date, lists };
91
+ }
92
+ export function deleteSnapshot(id) {
93
+ const db = getDb();
94
+ db.prepare(`DELETE FROM snapshot_tasks WHERE snapshotListId IN (SELECT id FROM snapshot_lists WHERE snapshotId = ?)`).run(id);
95
+ db.prepare("DELETE FROM snapshot_lists WHERE snapshotId = ?").run(id);
96
+ db.prepare("DELETE FROM snapshots WHERE id = ?").run(id);
97
+ }
package/dist/store.js ADDED
@@ -0,0 +1,253 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ let db;
6
+ let dbPath;
7
+ function getStoreDir() {
8
+ return path.join(os.homedir(), ".taskfile");
9
+ }
10
+ function getStoreFile() {
11
+ return path.join(getStoreDir(), "tasks.db");
12
+ }
13
+ function rowToTask(row) {
14
+ return {
15
+ id: row.id,
16
+ text: row.text,
17
+ done: !!row.done,
18
+ parentId: row.parentId,
19
+ priority: !!row.priority,
20
+ listId: row.listId,
21
+ createdAt: row.createdAt,
22
+ };
23
+ }
24
+ function rowToList(row) {
25
+ return { id: row.id, name: row.name, createdAt: row.createdAt };
26
+ }
27
+ function ensureSchema(database) {
28
+ database.exec(`
29
+ CREATE TABLE IF NOT EXISTS lists (
30
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
31
+ name TEXT NOT NULL,
32
+ createdAt TEXT NOT NULL
33
+ )
34
+ `);
35
+ database.exec(`
36
+ CREATE TABLE IF NOT EXISTS tasks (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ text TEXT NOT NULL,
39
+ done INTEGER NOT NULL DEFAULT 0,
40
+ createdAt TEXT NOT NULL,
41
+ parentId INTEGER REFERENCES tasks(id),
42
+ priority INTEGER NOT NULL DEFAULT 0,
43
+ listId INTEGER REFERENCES lists(id)
44
+ )
45
+ `);
46
+ database.exec(`
47
+ CREATE TABLE IF NOT EXISTS snapshots (
48
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
49
+ date TEXT NOT NULL UNIQUE,
50
+ createdAt TEXT NOT NULL
51
+ )
52
+ `);
53
+ database.exec(`
54
+ CREATE TABLE IF NOT EXISTS snapshot_lists (
55
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
56
+ snapshotId INTEGER NOT NULL REFERENCES snapshots(id),
57
+ name TEXT NOT NULL,
58
+ createdAt TEXT NOT NULL,
59
+ orderIndex INTEGER NOT NULL
60
+ )
61
+ `);
62
+ database.exec(`
63
+ CREATE TABLE IF NOT EXISTS snapshot_tasks (
64
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
65
+ snapshotListId INTEGER NOT NULL REFERENCES snapshot_lists(id),
66
+ text TEXT NOT NULL,
67
+ done INTEGER NOT NULL DEFAULT 0,
68
+ priority INTEGER NOT NULL DEFAULT 0,
69
+ parentSnapshotTaskId INTEGER REFERENCES snapshot_tasks(id),
70
+ createdAt TEXT NOT NULL,
71
+ orderIndex INTEGER NOT NULL
72
+ )
73
+ `);
74
+ const columns = database.prepare("PRAGMA table_info(tasks)").all();
75
+ const hasParentId = columns.some((column) => column.name === "parentId");
76
+ if (!hasParentId) {
77
+ database.exec("ALTER TABLE tasks ADD COLUMN parentId INTEGER REFERENCES tasks(id)");
78
+ }
79
+ const hasPriority = columns.some((column) => column.name === "priority");
80
+ if (!hasPriority) {
81
+ database.exec("ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 0");
82
+ }
83
+ const hasListId = columns.some((column) => column.name === "listId");
84
+ if (!hasListId) {
85
+ database.exec("ALTER TABLE tasks ADD COLUMN listId INTEGER REFERENCES lists(id)");
86
+ }
87
+ const { count } = database.prepare("SELECT COUNT(*) AS count FROM lists").get();
88
+ let defaultListId;
89
+ if (count === 0) {
90
+ const createdAt = new Date().toISOString();
91
+ const info = database.prepare("INSERT INTO lists (name, createdAt) VALUES (?, ?)").run("Todos", createdAt);
92
+ defaultListId = Number(info.lastInsertRowid);
93
+ }
94
+ else {
95
+ const first = database.prepare("SELECT id FROM lists ORDER BY id LIMIT 1").get();
96
+ defaultListId = first.id;
97
+ }
98
+ database.prepare("UPDATE tasks SET listId = ? WHERE listId IS NULL").run(defaultListId);
99
+ }
100
+ export function getDb() {
101
+ const currentPath = getStoreFile();
102
+ if (db && dbPath === currentPath) {
103
+ return db;
104
+ }
105
+ if (db) {
106
+ db.close();
107
+ }
108
+ const storeDir = getStoreDir();
109
+ if (!fs.existsSync(storeDir)) {
110
+ fs.mkdirSync(storeDir, { recursive: true });
111
+ }
112
+ db = new DatabaseSync(currentPath);
113
+ ensureSchema(db);
114
+ dbPath = currentPath;
115
+ return db;
116
+ }
117
+ export function loadTasks(listId) {
118
+ const rows = getDb()
119
+ .prepare(`SELECT id, text, done, parentId, priority, listId, createdAt FROM tasks
120
+ WHERE listId = ?
121
+ ORDER BY
122
+ (SELECT priority FROM tasks p WHERE p.id = COALESCE(tasks.parentId, tasks.id)) DESC,
123
+ COALESCE(parentId, id),
124
+ parentId IS NOT NULL,
125
+ priority DESC,
126
+ id`)
127
+ .all(listId);
128
+ return rows.map(rowToTask);
129
+ }
130
+ export function addTask(listId, text, parentId = null) {
131
+ const createdAt = new Date().toISOString();
132
+ const info = getDb()
133
+ .prepare("INSERT INTO tasks (text, done, createdAt, parentId, listId) VALUES (?, 0, ?, ?, ?)")
134
+ .run(text, createdAt, parentId, listId);
135
+ return { id: Number(info.lastInsertRowid), text, done: false, parentId, priority: false, listId, createdAt };
136
+ }
137
+ export function loadLists() {
138
+ const rows = getDb().prepare("SELECT id, name, createdAt FROM lists ORDER BY id").all();
139
+ return rows.map(rowToList);
140
+ }
141
+ export function createList(name) {
142
+ const trimmed = name.trim();
143
+ const createdAt = new Date().toISOString();
144
+ const info = getDb().prepare("INSERT INTO lists (name, createdAt) VALUES (?, ?)").run(trimmed, createdAt);
145
+ return { id: Number(info.lastInsertRowid), name: trimmed, createdAt };
146
+ }
147
+ export function renameList(id, name) {
148
+ const row = getDb().prepare("SELECT id, name, createdAt FROM lists WHERE id = ?").get(id);
149
+ if (!row) {
150
+ return { status: "not-found" };
151
+ }
152
+ const trimmed = name.trim();
153
+ if (trimmed.length === 0) {
154
+ return { status: "blocked", reason: "List name cannot be empty." };
155
+ }
156
+ getDb().prepare("UPDATE lists SET name = ? WHERE id = ?").run(trimmed, id);
157
+ return { status: "ok", list: { ...rowToList(row), name: trimmed } };
158
+ }
159
+ export function getDefaultListId() {
160
+ return loadLists()[0].id;
161
+ }
162
+ export function removeTask(id) {
163
+ const row = getDb()
164
+ .prepare("SELECT id, text, done, parentId, priority, listId, createdAt FROM tasks WHERE id = ?")
165
+ .get(id);
166
+ if (!row) {
167
+ return { status: "not-found" };
168
+ }
169
+ if (row.parentId === null) {
170
+ const { count } = getDb()
171
+ .prepare("SELECT COUNT(*) AS count FROM tasks WHERE parentId = ?")
172
+ .get(id);
173
+ if (count > 0) {
174
+ return { status: "blocked", reason: "Delete all sub-items first." };
175
+ }
176
+ }
177
+ getDb().prepare("DELETE FROM tasks WHERE id = ?").run(id);
178
+ return { status: "ok", task: rowToTask(row) };
179
+ }
180
+ export function toggleTask(id) {
181
+ const row = getDb()
182
+ .prepare("SELECT id, text, done, parentId, priority, listId, createdAt FROM tasks WHERE id = ?")
183
+ .get(id);
184
+ if (!row) {
185
+ return { status: "not-found" };
186
+ }
187
+ const nextDone = !row.done;
188
+ if (nextDone && row.parentId === null) {
189
+ const { count } = getDb()
190
+ .prepare("SELECT COUNT(*) AS count FROM tasks WHERE parentId = ? AND done = 0")
191
+ .get(id);
192
+ if (count > 0) {
193
+ return { status: "blocked", reason: "Complete all sub-items before completing this item." };
194
+ }
195
+ }
196
+ getDb()
197
+ .prepare("UPDATE tasks SET done = ? WHERE id = ?")
198
+ .run(nextDone ? 1 : 0, id);
199
+ if (!nextDone && row.parentId !== null) {
200
+ getDb().prepare("UPDATE tasks SET done = 0 WHERE id = ? AND done = 1").run(row.parentId);
201
+ }
202
+ const updated = getDb()
203
+ .prepare("SELECT id, text, done, parentId, priority, listId, createdAt FROM tasks WHERE id = ?")
204
+ .get(id);
205
+ return { status: "ok", task: rowToTask(updated) };
206
+ }
207
+ export function updateTask(id, text) {
208
+ const row = getDb()
209
+ .prepare("SELECT id, text, done, parentId, priority, listId, createdAt FROM tasks WHERE id = ?")
210
+ .get(id);
211
+ if (!row) {
212
+ return { status: "not-found" };
213
+ }
214
+ const trimmed = text.trim();
215
+ if (trimmed.length === 0) {
216
+ return { status: "blocked", reason: "Task text cannot be empty." };
217
+ }
218
+ getDb().prepare("UPDATE tasks SET text = ? WHERE id = ?").run(trimmed, id);
219
+ return { status: "ok", task: { ...rowToTask(row), text: trimmed } };
220
+ }
221
+ // Counts all tasks flat, ignoring parentId (main items and sub-items count equally).
222
+ export function computeProgress(tasks) {
223
+ const total = tasks.length;
224
+ const done = tasks.filter((task) => task.done).length;
225
+ const percent = total === 0 ? 0 : Math.round((done / total) * 100);
226
+ return { done, total, percent };
227
+ }
228
+ export function hasSubItems(id) {
229
+ const { count } = getDb()
230
+ .prepare("SELECT COUNT(*) AS count FROM tasks WHERE parentId = ?")
231
+ .get(id);
232
+ return count > 0;
233
+ }
234
+ export function setPriority(id, priority) {
235
+ const row = getDb()
236
+ .prepare("SELECT id, text, done, parentId, priority, listId, createdAt FROM tasks WHERE id = ?")
237
+ .get(id);
238
+ if (!row) {
239
+ return { status: "not-found" };
240
+ }
241
+ getDb()
242
+ .prepare("UPDATE tasks SET priority = ? WHERE id = ?")
243
+ .run(priority ? 1 : 0, id);
244
+ if (row.parentId === null) {
245
+ getDb()
246
+ .prepare("UPDATE tasks SET priority = ? WHERE parentId = ?")
247
+ .run(priority ? 1 : 0, id);
248
+ }
249
+ const updated = getDb()
250
+ .prepare("SELECT id, text, done, parentId, priority, listId, createdAt FROM tasks WHERE id = ?")
251
+ .get(id);
252
+ return { status: "ok", task: rowToTask(updated) };
253
+ }
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text } from "ink";
4
+ import TextInput from "ink-text-input";
5
+ export function AddTaskInput({ onSubmit, label = "New task: ", initialValue = "", }) {
6
+ const [value, setValue] = useState(initialValue);
7
+ return (_jsxs(Box, { children: [_jsx(Text, { children: label }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: (text) => {
8
+ const trimmed = text.trim();
9
+ if (trimmed.length > 0) {
10
+ onSubmit(trimmed);
11
+ }
12
+ } })] }));
13
+ }