ttrak 0.1.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.
@@ -0,0 +1,427 @@
1
+ import {
2
+ BoxRenderable,
3
+ TextRenderable,
4
+ InputRenderable,
5
+ TabSelectRenderable,
6
+ TabSelectRenderableEvents,
7
+ type CliRenderer,
8
+ type KeyEvent,
9
+ } from "@opentui/core";
10
+ import type { Task, DataStore } from "../../schema";
11
+ import type { Theme } from "../theme";
12
+ import { TaskItem } from "../components/TaskItem";
13
+ import { TaskModal, type TaskModalResult } from "../components/TaskModal";
14
+ import { saveDataStore } from "../../data/store";
15
+
16
+ type FilterTab = "all" | "todo" | "inProgress" | "done";
17
+
18
+ export class TaskListView {
19
+ private renderer: CliRenderer;
20
+ private theme: Theme;
21
+ private container: BoxRenderable;
22
+ private header: TextRenderable;
23
+ private searchBar: InputRenderable;
24
+ private tabBar: TabSelectRenderable;
25
+ private listContainer: BoxRenderable;
26
+ private emptyText: TextRenderable;
27
+ private footer: TextRenderable;
28
+ private taskItems: TaskItem[] = [];
29
+ private selectedIndex = 0;
30
+ private store: DataStore;
31
+ private boundHandleKeyPress: (key: KeyEvent) => void;
32
+ private activeFilter: FilterTab = "all";
33
+ private searchQuery = "";
34
+ private modal: TaskModal;
35
+ private modalOpen = false;
36
+ private confirmDelete = false;
37
+ private searchMode = false;
38
+ private onQuit: () => void;
39
+
40
+ constructor(
41
+ renderer: CliRenderer,
42
+ theme: Theme,
43
+ store: DataStore,
44
+ onQuit: () => void,
45
+ ) {
46
+ this.renderer = renderer;
47
+ this.theme = theme;
48
+ this.store = store;
49
+ this.onQuit = onQuit;
50
+ this.boundHandleKeyPress = this.handleKeyPress.bind(this);
51
+
52
+ this.container = new BoxRenderable(renderer, {
53
+ id: "task-list-view",
54
+ width: "100%",
55
+ height: "100%",
56
+ flexDirection: "column",
57
+ backgroundColor: theme.bg,
58
+ });
59
+
60
+ this.header = new TextRenderable(renderer, {
61
+ id: "header",
62
+ content: this.getHeaderText(),
63
+ fg: theme.accent,
64
+ height: 1,
65
+ flexShrink: 0,
66
+ marginLeft: 1,
67
+ });
68
+
69
+ this.searchBar = new InputRenderable(renderer, {
70
+ id: "search-bar",
71
+ width: "100%",
72
+ height: 1,
73
+ placeholder: "Search tasks... (Press / to focus)",
74
+ backgroundColor: theme.bg,
75
+ focusedBackgroundColor: theme.border,
76
+ textColor: theme.fg,
77
+ focusedTextColor: theme.fg,
78
+ placeholderColor: theme.muted,
79
+ cursorColor: theme.accent,
80
+ flexShrink: 0,
81
+ marginLeft: 1,
82
+ marginRight: 1,
83
+ });
84
+
85
+ this.tabBar = new TabSelectRenderable(renderer, {
86
+ id: "filter-tabs",
87
+ width: "100%",
88
+ height: 3,
89
+ options: [
90
+ { name: "All", description: "", value: "all" },
91
+ { name: "Todo", description: "", value: "todo" },
92
+ { name: "In Progress", description: "", value: "inProgress" },
93
+ { name: "Done", description: "", value: "done" },
94
+ ],
95
+ tabWidth: 14,
96
+ backgroundColor: theme.bg,
97
+ focusedBackgroundColor: theme.bg,
98
+ textColor: theme.muted,
99
+ focusedTextColor: theme.fg,
100
+ selectedBackgroundColor: theme.bg,
101
+ selectedTextColor: theme.accent,
102
+ showDescription: false,
103
+ showUnderline: true,
104
+ showScrollArrows: false,
105
+ flexShrink: 0,
106
+ });
107
+
108
+ this.listContainer = new BoxRenderable(renderer, {
109
+ id: "list-container",
110
+ width: "100%",
111
+ flexDirection: "column",
112
+ flexGrow: 1,
113
+ backgroundColor: theme.bg,
114
+ });
115
+
116
+ this.emptyText = new TextRenderable(renderer, {
117
+ id: "empty-text",
118
+ content: " No tasks. Press 'n' to create one.",
119
+ fg: theme.muted,
120
+ flexGrow: 1,
121
+ });
122
+ this.emptyText.visible = false;
123
+
124
+ this.footer = new TextRenderable(renderer, {
125
+ id: "footer",
126
+ content:
127
+ "/:search j/k:nav n:new e:edit d:del space:status 1-4:filter q:quit",
128
+ fg: theme.muted,
129
+ height: 1,
130
+ flexShrink: 0,
131
+ marginLeft: 1,
132
+ });
133
+
134
+ this.container.add(this.header);
135
+ this.container.add(this.searchBar);
136
+ this.container.add(this.tabBar);
137
+ this.container.add(this.listContainer);
138
+ this.container.add(this.emptyText);
139
+ this.container.add(this.footer);
140
+
141
+ this.modal = new TaskModal(renderer, theme);
142
+
143
+ this.renderTasks();
144
+ renderer.root.add(this.container);
145
+ this.setupEventHandlers();
146
+ }
147
+
148
+ private getHeaderText(): string {
149
+ const filtered = this.getFilteredTasks();
150
+ const total = this.store.tasks.length;
151
+ return ` TTRAK - ${filtered.length}/${total} tasks`;
152
+ }
153
+
154
+ private getFilteredTasks(): Task[] {
155
+ let tasks = this.store.tasks;
156
+
157
+ if (this.activeFilter !== "all") {
158
+ tasks = tasks.filter((t) => t.status === this.activeFilter);
159
+ }
160
+
161
+ if (this.searchQuery) {
162
+ const query = this.searchQuery.toLowerCase();
163
+ tasks = tasks.filter(
164
+ (t) =>
165
+ t.title.toLowerCase().includes(query) ||
166
+ t.id.toLowerCase().includes(query) ||
167
+ t.description?.toLowerCase().includes(query),
168
+ );
169
+ }
170
+
171
+ return tasks;
172
+ }
173
+
174
+ private renderTasks() {
175
+ this.taskItems.forEach((item) => item.destroy());
176
+ this.taskItems = [];
177
+
178
+ const tasks = this.getFilteredTasks();
179
+
180
+ if (tasks.length === 0) {
181
+ this.emptyText.visible = true;
182
+ this.listContainer.visible = false;
183
+ } else {
184
+ this.emptyText.visible = false;
185
+ this.listContainer.visible = true;
186
+
187
+ tasks.forEach((task, index) => {
188
+ const item = new TaskItem(this.renderer, task, this.theme, index);
189
+ this.taskItems.push(item);
190
+ this.listContainer.add(item.getRenderable());
191
+ });
192
+
193
+ this.selectedIndex = Math.min(this.selectedIndex, tasks.length - 1);
194
+ this.selectedIndex = Math.max(0, this.selectedIndex);
195
+ this.updateSelection();
196
+ }
197
+
198
+ this.header.content = this.getHeaderText();
199
+ }
200
+
201
+ private setupEventHandlers() {
202
+ this.renderer.keyInput.on("keypress", this.boundHandleKeyPress);
203
+
204
+ this.tabBar.on(
205
+ TabSelectRenderableEvents.SELECTION_CHANGED,
206
+ (_index, option) => {
207
+ this.activeFilter = option.value as FilterTab;
208
+ this.selectedIndex = 0;
209
+ this.renderTasks();
210
+ },
211
+ );
212
+
213
+ this.searchBar.on("input", () => {
214
+ this.searchQuery = this.searchBar.value;
215
+ this.selectedIndex = 0;
216
+ this.renderTasks();
217
+ });
218
+ }
219
+
220
+ private handleKeyPress(key: KeyEvent) {
221
+ if (this.modalOpen) return;
222
+
223
+ if (this.searchMode) {
224
+ if (key.name === "escape") {
225
+ this.searchMode = false;
226
+ this.searchBar.blur();
227
+ }
228
+ return;
229
+ }
230
+
231
+ if (this.confirmDelete) {
232
+ if (key.name === "y") {
233
+ this.deleteSelectedTask();
234
+ this.confirmDelete = false;
235
+ this.footer.content =
236
+ "/:search j/k:nav n:new e:edit d:del space:status 1-4:filter q:quit";
237
+ this.footer.fg = this.theme.muted;
238
+ } else if (key.name === "n" || key.name === "escape") {
239
+ this.confirmDelete = false;
240
+ this.footer.content =
241
+ "/:search j/k:nav n:new e:edit d:del space:status 1-4:filter q:quit";
242
+ this.footer.fg = this.theme.muted;
243
+ }
244
+ return;
245
+ }
246
+
247
+ switch (key.name) {
248
+ case "/":
249
+ this.searchMode = true;
250
+ queueMicrotask(() => {
251
+ this.searchBar.focus();
252
+ });
253
+ break;
254
+ case "j":
255
+ case "down":
256
+ this.moveSelection(1);
257
+ break;
258
+ case "k":
259
+ case "up":
260
+ this.moveSelection(-1);
261
+ break;
262
+ case "g":
263
+ if (!key.shift) {
264
+ this.selectedIndex = 0;
265
+ this.updateSelection();
266
+ }
267
+ break;
268
+ case "G":
269
+ this.selectedIndex = Math.max(0, this.taskItems.length - 1);
270
+ this.updateSelection();
271
+ break;
272
+ case "n":
273
+ this.openCreateModal();
274
+ break;
275
+ case "e":
276
+ this.openEditModal();
277
+ break;
278
+ case "d":
279
+ if (this.taskItems.length > 0) {
280
+ this.confirmDelete = true;
281
+ this.footer.content = "Delete task? y:yes n:no";
282
+ this.footer.fg = this.theme.warning;
283
+ }
284
+ break;
285
+ case "space":
286
+ this.cycleTaskStatus();
287
+ break;
288
+ case "1":
289
+ this.setFilter(0);
290
+ break;
291
+ case "2":
292
+ this.setFilter(1);
293
+ break;
294
+ case "3":
295
+ this.setFilter(2);
296
+ break;
297
+ case "4":
298
+ this.setFilter(3);
299
+ break;
300
+ case "q":
301
+ this.destroy();
302
+ this.onQuit();
303
+ break;
304
+ }
305
+ }
306
+
307
+ private setFilter(index: number) {
308
+ this.tabBar.setSelectedIndex(index);
309
+ const filters: FilterTab[] = ["all", "todo", "inProgress", "done"];
310
+ this.activeFilter = filters[index] ?? "all";
311
+ this.selectedIndex = 0;
312
+ this.renderTasks();
313
+ }
314
+
315
+ private moveSelection(delta: number) {
316
+ if (this.taskItems.length === 0) return;
317
+ this.selectedIndex = Math.max(
318
+ 0,
319
+ Math.min(this.taskItems.length - 1, this.selectedIndex + delta),
320
+ );
321
+ this.updateSelection();
322
+ }
323
+
324
+ private updateSelection() {
325
+ this.taskItems.forEach((item, index) => {
326
+ item.setSelected(index === this.selectedIndex);
327
+ });
328
+ }
329
+
330
+ private async openCreateModal() {
331
+ this.modalOpen = true;
332
+ const result = await this.modal.show("create");
333
+ this.modalOpen = false;
334
+
335
+ if (result) {
336
+ this.createTask(result);
337
+ }
338
+ }
339
+
340
+ private async openEditModal() {
341
+ const taskItem = this.taskItems[this.selectedIndex];
342
+ if (!taskItem) return;
343
+
344
+ const task = taskItem.getTask();
345
+ this.modalOpen = true;
346
+ const result = await this.modal.show("edit", task);
347
+ this.modalOpen = false;
348
+
349
+ if (result) {
350
+ this.updateTask(task.id, result);
351
+ }
352
+ }
353
+
354
+ private createTask(data: TaskModalResult) {
355
+ const now = new Date().toISOString();
356
+ const maxId = this.store.tasks
357
+ .filter((t) => t.id.startsWith("LOCAL-"))
358
+ .map((t) => parseInt(t.id.replace("LOCAL-", ""), 10))
359
+ .reduce((max, n) => Math.max(max, n), 0);
360
+
361
+ const newTask: Task = {
362
+ id: `LOCAL-${maxId + 1}`,
363
+ title: data.title,
364
+ status: data.status,
365
+ priority: data.priority,
366
+ createdAt: now,
367
+ updatedAt: now,
368
+ };
369
+
370
+ this.store.tasks.unshift(newTask);
371
+ this.saveAndRender();
372
+ }
373
+
374
+ private updateTask(id: string, data: TaskModalResult) {
375
+ const task = this.store.tasks.find((t) => t.id === id);
376
+ if (!task) return;
377
+
378
+ task.title = data.title;
379
+ task.priority = data.priority;
380
+ task.status = data.status;
381
+ task.updatedAt = new Date().toISOString();
382
+ this.saveAndRender();
383
+ }
384
+
385
+ private deleteSelectedTask() {
386
+ const taskItem = this.taskItems[this.selectedIndex];
387
+ if (!taskItem) return;
388
+
389
+ const task = taskItem.getTask();
390
+ const index = this.store.tasks.findIndex((t) => t.id === task.id);
391
+ if (index >= 0) {
392
+ this.store.tasks.splice(index, 1);
393
+ this.saveAndRender();
394
+ }
395
+ }
396
+
397
+ private cycleTaskStatus() {
398
+ const taskItem = this.taskItems[this.selectedIndex];
399
+ if (!taskItem) return;
400
+
401
+ const task = taskItem.getTask();
402
+ const storeTask = this.store.tasks.find((t) => t.id === task.id);
403
+ if (!storeTask) return;
404
+
405
+ const statusOrder: Task["status"][] = ["todo", "inProgress", "done"];
406
+ const currentIndex = statusOrder.indexOf(storeTask.status);
407
+ const nextStatus = statusOrder[(currentIndex + 1) % statusOrder.length];
408
+ if (nextStatus) {
409
+ storeTask.status = nextStatus;
410
+ storeTask.updatedAt = new Date().toISOString();
411
+ this.saveAndRender();
412
+ }
413
+ }
414
+
415
+ private async saveAndRender() {
416
+ await saveDataStore(this.store);
417
+ this.renderTasks();
418
+ this.footer.fg = this.theme.muted;
419
+ }
420
+
421
+ destroy() {
422
+ this.renderer.keyInput.off("keypress", this.boundHandleKeyPress);
423
+ this.taskItems.forEach((item) => item.destroy());
424
+ this.modal.destroy();
425
+ this.container.destroy();
426
+ }
427
+ }
package/test-store.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { loadDataStore } from "./src/data/store";
2
+
3
+ const store = await loadDataStore();
4
+ console.log("Loaded store:");
5
+ console.log(JSON.stringify(store, null, 2));
6
+ console.log(`\nTasks: ${store.tasks.length}`);
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"],
4
+ "target": "ESNext",
5
+ "module": "Preserve",
6
+ "moduleDetection": "force",
7
+ "allowJs": true,
8
+
9
+ "moduleResolution": "bundler",
10
+ "allowImportingTsExtensions": true,
11
+ "verbatimModuleSyntax": true,
12
+ "noEmit": true,
13
+
14
+ "strict": true,
15
+ "skipLibCheck": true,
16
+ "noFallthroughCasesInSwitch": true,
17
+ "noUncheckedIndexedAccess": true,
18
+ "noImplicitOverride": true,
19
+
20
+ "noUnusedLocals": false,
21
+ "noUnusedParameters": false,
22
+ "noPropertyAccessFromIndexSignature": false
23
+ }
24
+ }