floq 0.2.2 → 0.3.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.
Files changed (37) hide show
  1. package/README.ja.md +13 -0
  2. package/README.md +13 -0
  3. package/dist/i18n/en.d.ts +16 -0
  4. package/dist/i18n/en.js +10 -1
  5. package/dist/i18n/ja.js +10 -1
  6. package/dist/ui/App.js +132 -64
  7. package/dist/ui/SplashScreen.js +1 -1
  8. package/dist/ui/components/HelpModal.js +3 -2
  9. package/dist/ui/components/KanbanBoard.js +128 -46
  10. package/dist/ui/components/SearchResults.js +2 -2
  11. package/dist/ui/history/HistoryContext.d.ts +29 -0
  12. package/dist/ui/history/HistoryContext.js +44 -0
  13. package/dist/ui/history/HistoryManager.d.ts +56 -0
  14. package/dist/ui/history/HistoryManager.js +137 -0
  15. package/dist/ui/history/commands/ConvertToProjectCommand.d.ts +19 -0
  16. package/dist/ui/history/commands/ConvertToProjectCommand.js +37 -0
  17. package/dist/ui/history/commands/CreateCommentCommand.d.ts +18 -0
  18. package/dist/ui/history/commands/CreateCommentCommand.js +23 -0
  19. package/dist/ui/history/commands/CreateTaskCommand.d.ts +18 -0
  20. package/dist/ui/history/commands/CreateTaskCommand.js +24 -0
  21. package/dist/ui/history/commands/DeleteCommentCommand.d.ts +17 -0
  22. package/dist/ui/history/commands/DeleteCommentCommand.js +26 -0
  23. package/dist/ui/history/commands/DeleteTaskCommand.d.ts +18 -0
  24. package/dist/ui/history/commands/DeleteTaskCommand.js +51 -0
  25. package/dist/ui/history/commands/LinkTaskCommand.d.ts +20 -0
  26. package/dist/ui/history/commands/LinkTaskCommand.js +37 -0
  27. package/dist/ui/history/commands/MoveTaskCommand.d.ts +25 -0
  28. package/dist/ui/history/commands/MoveTaskCommand.js +43 -0
  29. package/dist/ui/history/commands/index.d.ts +7 -0
  30. package/dist/ui/history/commands/index.js +7 -0
  31. package/dist/ui/history/index.d.ts +6 -0
  32. package/dist/ui/history/index.js +8 -0
  33. package/dist/ui/history/types.d.ts +26 -0
  34. package/dist/ui/history/types.js +4 -0
  35. package/dist/ui/history/useHistory.d.ts +24 -0
  36. package/dist/ui/history/useHistory.js +31 -0
  37. package/package.json +1 -1
@@ -14,10 +14,12 @@ import { t, fmt } from '../../i18n/index.js';
14
14
  import { useTheme } from '../theme/index.js';
15
15
  import { isTursoEnabled } from '../../config.js';
16
16
  import { VERSION } from '../../version.js';
17
+ import { useHistory, CreateTaskCommand, MoveTaskCommand, LinkTaskCommand, CreateCommentCommand, DeleteCommentCommand, } from '../history/index.js';
17
18
  const COLUMNS = ['todo', 'doing', 'done'];
18
19
  export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
19
20
  const theme = useTheme();
20
21
  const { exit } = useApp();
22
+ const history = useHistory();
21
23
  const [mode, setMode] = useState('normal');
22
24
  const [inputValue, setInputValue] = useState('');
23
25
  const [currentColumnIndex, setCurrentColumnIndex] = useState(0);
@@ -87,32 +89,42 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
87
89
  setTaskComments(comments);
88
90
  }, []);
89
91
  const addCommentToTask = useCallback(async (task, content) => {
90
- const db = getDb();
91
- await db.insert(schema.comments).values({
92
- id: uuidv4(),
93
- taskId: task.id,
94
- content: content.trim(),
95
- createdAt: new Date(),
92
+ const commentId = uuidv4();
93
+ const command = new CreateCommentCommand({
94
+ comment: {
95
+ id: commentId,
96
+ taskId: task.id,
97
+ content: content.trim(),
98
+ createdAt: new Date(),
99
+ },
100
+ description: i18n.tui.commentAdded || 'Comment added',
96
101
  });
102
+ await history.execute(command);
97
103
  setMessage(i18n.tui.commentAdded || 'Comment added');
98
104
  await loadTaskComments(task.id);
99
- }, [i18n.tui.commentAdded, loadTaskComments]);
105
+ }, [i18n.tui.commentAdded, loadTaskComments, history]);
100
106
  const deleteComment = useCallback(async (comment) => {
101
- const db = getDb();
102
- await db.delete(schema.comments).where(eq(schema.comments.id, comment.id));
107
+ const command = new DeleteCommentCommand({
108
+ comment,
109
+ description: i18n.tui.commentDeleted || 'Comment deleted',
110
+ });
111
+ await history.execute(command);
103
112
  setMessage(i18n.tui.commentDeleted || 'Comment deleted');
104
113
  if (selectedTask) {
105
114
  await loadTaskComments(selectedTask.id);
106
115
  }
107
- }, [i18n.tui.commentDeleted, loadTaskComments, selectedTask]);
116
+ }, [i18n.tui.commentDeleted, loadTaskComments, selectedTask, history]);
108
117
  const linkTaskToProject = useCallback(async (task, project) => {
109
- const db = getDb();
110
- await db.update(schema.tasks)
111
- .set({ parentId: project.id, updatedAt: new Date() })
112
- .where(eq(schema.tasks.id, task.id));
118
+ const command = new LinkTaskCommand({
119
+ taskId: task.id,
120
+ fromParentId: task.parentId,
121
+ toParentId: project.id,
122
+ description: fmt(i18n.tui.linkedToProject || 'Linked "{title}" to {project}', { title: task.title, project: project.title }),
123
+ });
124
+ await history.execute(command);
113
125
  setMessage(fmt(i18n.tui.linkedToProject || 'Linked "{title}" to {project}', { title: task.title, project: project.title }));
114
126
  await loadTasks();
115
- }, [i18n.tui.linkedToProject, loadTasks]);
127
+ }, [i18n.tui.linkedToProject, loadTasks, history]);
116
128
  const currentColumn = COLUMNS[currentColumnIndex];
117
129
  const currentTasks = tasks[currentColumn];
118
130
  const selectedTaskIndex = selectedTaskIndices[currentColumn];
@@ -140,22 +152,49 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
140
152
  setSearchResults(results);
141
153
  setSearchResultIndex(0);
142
154
  }, [searchTasks]);
155
+ // Navigate to a task from search results
156
+ const navigateToTask = useCallback((task) => {
157
+ // Determine which column the task belongs to based on status
158
+ let targetColumn;
159
+ if (task.status === 'inbox' || task.status === 'someday') {
160
+ targetColumn = 'todo';
161
+ }
162
+ else if (task.status === 'next' || task.status === 'waiting') {
163
+ targetColumn = 'doing';
164
+ }
165
+ else {
166
+ targetColumn = 'done';
167
+ }
168
+ const columnIndex = COLUMNS.indexOf(targetColumn);
169
+ const taskIndex = tasks[targetColumn].findIndex(t => t.id === task.id);
170
+ if (columnIndex >= 0 && taskIndex >= 0) {
171
+ setCurrentColumnIndex(columnIndex);
172
+ setSelectedTaskIndices(prev => ({
173
+ ...prev,
174
+ [targetColumn]: taskIndex,
175
+ }));
176
+ setMode('normal');
177
+ }
178
+ }, [tasks]);
143
179
  const addTask = useCallback(async (title) => {
144
180
  if (!title.trim())
145
181
  return;
146
- const db = getDb();
147
182
  const now = new Date();
148
- await db.insert(schema.tasks)
149
- .values({
150
- id: uuidv4(),
151
- title: title.trim(),
152
- status: 'inbox', // New tasks go to inbox (which maps to TODO)
153
- createdAt: now,
154
- updatedAt: now,
183
+ const taskId = uuidv4();
184
+ const command = new CreateTaskCommand({
185
+ task: {
186
+ id: taskId,
187
+ title: title.trim(),
188
+ status: 'inbox', // New tasks go to inbox (which maps to TODO)
189
+ createdAt: now,
190
+ updatedAt: now,
191
+ },
192
+ description: fmt(i18n.tui.added, { title: title.trim() }),
155
193
  });
194
+ await history.execute(command);
156
195
  setMessage(fmt(i18n.tui.added, { title: title.trim() }));
157
196
  await loadTasks();
158
- }, [i18n.tui.added, loadTasks]);
197
+ }, [i18n.tui.added, loadTasks, history]);
159
198
  const handleInputSubmit = async (value) => {
160
199
  if (mode === 'add-comment' && selectedTask) {
161
200
  if (value.trim()) {
@@ -169,9 +208,7 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
169
208
  if (mode === 'search') {
170
209
  if (searchResults.length > 0) {
171
210
  const task = searchResults[searchResultIndex];
172
- setSelectedTask(task);
173
- loadTaskComments(task.id);
174
- setMode('task-detail');
211
+ navigateToTask(task);
175
212
  }
176
213
  else {
177
214
  setMode('normal');
@@ -188,7 +225,6 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
188
225
  setMode('normal');
189
226
  };
190
227
  const moveTaskRight = useCallback(async (task) => {
191
- const db = getDb();
192
228
  let newStatus;
193
229
  // Determine new status based on current status
194
230
  if (task.status === 'inbox' || task.status === 'someday') {
@@ -203,14 +239,19 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
203
239
  // Already done, do nothing
204
240
  return;
205
241
  }
206
- await db.update(schema.tasks)
207
- .set({ status: newStatus, waitingFor: null, updatedAt: new Date() })
208
- .where(eq(schema.tasks.id, task.id));
242
+ const command = new MoveTaskCommand({
243
+ taskId: task.id,
244
+ fromStatus: task.status,
245
+ toStatus: newStatus,
246
+ fromWaitingFor: task.waitingFor,
247
+ toWaitingFor: null,
248
+ description: fmt(i18n.tui.movedTo, { title: task.title, status: i18n.status[newStatus] }),
249
+ });
250
+ await history.execute(command);
209
251
  setMessage(fmt(i18n.tui.movedTo, { title: task.title, status: i18n.status[newStatus] }));
210
252
  await loadTasks();
211
- }, [i18n.tui.movedTo, i18n.status, loadTasks]);
253
+ }, [i18n.tui.movedTo, i18n.status, loadTasks, history]);
212
254
  const moveTaskLeft = useCallback(async (task) => {
213
- const db = getDb();
214
255
  let newStatus;
215
256
  // Determine new status based on current status
216
257
  if (task.status === 'next' || task.status === 'waiting') {
@@ -225,20 +266,31 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
225
266
  // Already in TODO, do nothing
226
267
  return;
227
268
  }
228
- await db.update(schema.tasks)
229
- .set({ status: newStatus, waitingFor: null, updatedAt: new Date() })
230
- .where(eq(schema.tasks.id, task.id));
269
+ const command = new MoveTaskCommand({
270
+ taskId: task.id,
271
+ fromStatus: task.status,
272
+ toStatus: newStatus,
273
+ fromWaitingFor: task.waitingFor,
274
+ toWaitingFor: null,
275
+ description: fmt(i18n.tui.movedTo, { title: task.title, status: i18n.status[newStatus] }),
276
+ });
277
+ await history.execute(command);
231
278
  setMessage(fmt(i18n.tui.movedTo, { title: task.title, status: i18n.status[newStatus] }));
232
279
  await loadTasks();
233
- }, [i18n.tui.movedTo, i18n.status, loadTasks]);
280
+ }, [i18n.tui.movedTo, i18n.status, loadTasks, history]);
234
281
  const markTaskDone = useCallback(async (task) => {
235
- const db = getDb();
236
- await db.update(schema.tasks)
237
- .set({ status: 'done', updatedAt: new Date() })
238
- .where(eq(schema.tasks.id, task.id));
282
+ const command = new MoveTaskCommand({
283
+ taskId: task.id,
284
+ fromStatus: task.status,
285
+ toStatus: 'done',
286
+ fromWaitingFor: task.waitingFor,
287
+ toWaitingFor: null,
288
+ description: fmt(i18n.tui.completed, { title: task.title }),
289
+ });
290
+ await history.execute(command);
239
291
  setMessage(fmt(i18n.tui.completed, { title: task.title }));
240
292
  await loadTasks();
241
- }, [i18n.tui.completed, loadTasks]);
293
+ }, [i18n.tui.completed, loadTasks, history]);
242
294
  const getColumnLabel = (column) => {
243
295
  return i18n.kanban[column];
244
296
  };
@@ -257,12 +309,12 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
257
309
  setMode('normal');
258
310
  return;
259
311
  }
260
- // Navigate search results with Ctrl+j/k or Ctrl+n/p
261
- if (key.ctrl && (input === 'j' || input === 'n')) {
312
+ // Navigate search results with arrow keys, Ctrl+j/k, or Ctrl+n/p
313
+ if (key.downArrow || (key.ctrl && (input === 'j' || input === 'n'))) {
262
314
  setSearchResultIndex((prev) => prev < searchResults.length - 1 ? prev + 1 : 0);
263
315
  return;
264
316
  }
265
- if (key.ctrl && (input === 'k' || input === 'p')) {
317
+ if (key.upArrow || (key.ctrl && (input === 'k' || input === 'p'))) {
266
318
  setSearchResultIndex((prev) => prev > 0 ? prev - 1 : Math.max(0, searchResults.length - 1));
267
319
  return;
268
320
  }
@@ -478,11 +530,41 @@ export function KanbanBoard({ onSwitchToGtd, onOpenSettings }) {
478
530
  return;
479
531
  }
480
532
  // Refresh
481
- if (input === 'r') {
533
+ if (input === 'r' && !(key.ctrl)) {
482
534
  loadTasks();
483
535
  setMessage(i18n.tui.refreshed);
484
536
  return;
485
537
  }
538
+ // Undo (u key) - only in normal mode
539
+ if (input === 'u' && mode === 'normal') {
540
+ history.undo().then((didUndo) => {
541
+ if (didUndo) {
542
+ setMessage(fmt(i18n.tui.undone, { action: history.undoDescription || '' }));
543
+ loadTasks();
544
+ }
545
+ else {
546
+ setMessage(i18n.tui.nothingToUndo);
547
+ }
548
+ }).catch(() => {
549
+ setMessage(i18n.tui.undoFailed);
550
+ });
551
+ return;
552
+ }
553
+ // Redo (Ctrl+r) - only in normal mode
554
+ if (key.ctrl && input === 'r' && mode === 'normal') {
555
+ history.redo().then((didRedo) => {
556
+ if (didRedo) {
557
+ setMessage(fmt(i18n.tui.redone, { action: history.redoDescription || '' }));
558
+ loadTasks();
559
+ }
560
+ else {
561
+ setMessage(i18n.tui.nothingToRedo);
562
+ }
563
+ }).catch(() => {
564
+ setMessage(i18n.tui.redoFailed);
565
+ });
566
+ return;
567
+ }
486
568
  });
487
569
  // Help modal overlay
488
570
  if (mode === 'help') {
@@ -12,7 +12,7 @@ export function SearchResults({ results, selectedIndex, query }) {
12
12
  return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.borders.list, borderColor: theme.colors.borderActive, paddingX: 1, paddingY: 1, minHeight: 5, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: theme.colors.secondary, bold: true, children: ["[", search.resultsTitle, "] (", results.length, ")"] }) }), results.length === 0 ? (_jsx(Text, { color: theme.colors.textMuted, italic: true, children: search.noResults })) : (results.slice(0, 10).map((task, index) => {
13
13
  const isSelected = index === selectedIndex;
14
14
  const shortId = task.id.slice(0, 8);
15
- const statusLabel = i18n.status[task.status];
16
- return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: [isSelected ? theme.style.selectedPrefix : theme.style.unselectedPrefix, "[", shortId, "] ", task.title, _jsxs(Text, { color: theme.colors.textMuted, children: [" (", statusLabel, ")"] }), task.waitingFor && (_jsxs(Text, { color: theme.colors.statusWaiting, children: [" - ", task.waitingFor] }))] }) }, task.id));
15
+ const displayLabel = task.isProject ? i18n.tui.keyBar.project : i18n.status[task.status];
16
+ return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: [isSelected ? theme.style.selectedPrefix : theme.style.unselectedPrefix, "[", shortId, "] ", task.title, _jsxs(Text, { color: theme.colors.textMuted, children: [" (", displayLabel, ")"] }), task.waitingFor && (_jsxs(Text, { color: theme.colors.statusWaiting, children: [" - ", task.waitingFor] }))] }) }, task.id));
17
17
  })), results.length > 10 && (_jsxs(Text, { color: theme.colors.textMuted, italic: true, children: ["... and ", results.length - 10, " more"] }))] }));
18
18
  }
@@ -0,0 +1,29 @@
1
+ import React from 'react';
2
+ import type { UndoableCommand, HistoryState } from './types.js';
3
+ interface HistoryContextValue {
4
+ /** Execute a command and add to history */
5
+ execute: (command: UndoableCommand) => Promise<void>;
6
+ /** Undo the last command */
7
+ undo: () => Promise<boolean>;
8
+ /** Redo the last undone command */
9
+ redo: () => Promise<boolean>;
10
+ /** Check if undo is available */
11
+ canUndo: boolean;
12
+ /** Check if redo is available */
13
+ canRedo: boolean;
14
+ /** Get the current history state */
15
+ state: HistoryState;
16
+ /** Description of command that would be undone */
17
+ undoDescription: string | null;
18
+ /** Description of command that would be redone */
19
+ redoDescription: string | null;
20
+ }
21
+ interface HistoryProviderProps {
22
+ children: React.ReactNode;
23
+ }
24
+ export declare function HistoryProvider({ children }: HistoryProviderProps): React.ReactElement;
25
+ /**
26
+ * Hook to access history context
27
+ */
28
+ export declare function useHistoryContext(): HistoryContextValue;
29
+ export {};
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react';
3
+ import { getHistoryManager } from './HistoryManager.js';
4
+ const HistoryContext = createContext(null);
5
+ export function HistoryProvider({ children }) {
6
+ const manager = useMemo(() => getHistoryManager(), []);
7
+ const [state, setState] = useState(manager.getState());
8
+ useEffect(() => {
9
+ const unsubscribe = manager.subscribe(() => {
10
+ setState(manager.getState());
11
+ });
12
+ return unsubscribe;
13
+ }, [manager]);
14
+ const execute = useCallback(async (command) => {
15
+ await manager.execute(command);
16
+ }, [manager]);
17
+ const undo = useCallback(async () => {
18
+ return manager.undo();
19
+ }, [manager]);
20
+ const redo = useCallback(async () => {
21
+ return manager.redo();
22
+ }, [manager]);
23
+ const value = useMemo(() => ({
24
+ execute,
25
+ undo,
26
+ redo,
27
+ canUndo: manager.canUndo(),
28
+ canRedo: manager.canRedo(),
29
+ state,
30
+ undoDescription: manager.getUndoDescription(),
31
+ redoDescription: manager.getRedoDescription(),
32
+ }), [execute, undo, redo, state, manager]);
33
+ return _jsx(HistoryContext.Provider, { value: value, children: children });
34
+ }
35
+ /**
36
+ * Hook to access history context
37
+ */
38
+ export function useHistoryContext() {
39
+ const context = useContext(HistoryContext);
40
+ if (!context) {
41
+ throw new Error('useHistoryContext must be used within a HistoryProvider');
42
+ }
43
+ return context;
44
+ }
@@ -0,0 +1,56 @@
1
+ import type { UndoableCommand, HistoryState } from './types.js';
2
+ /**
3
+ * Manages undo/redo history using the Command Pattern
4
+ */
5
+ export declare class HistoryManager {
6
+ private undoStack;
7
+ private redoStack;
8
+ private listeners;
9
+ /**
10
+ * Execute a command and add it to the undo stack
11
+ */
12
+ execute(command: UndoableCommand): Promise<void>;
13
+ /**
14
+ * Undo the last command
15
+ * @returns true if undo was performed, false if nothing to undo
16
+ */
17
+ undo(): Promise<boolean>;
18
+ /**
19
+ * Redo the last undone command
20
+ * @returns true if redo was performed, false if nothing to redo
21
+ */
22
+ redo(): Promise<boolean>;
23
+ /**
24
+ * Get the current history state
25
+ */
26
+ getState(): HistoryState;
27
+ /**
28
+ * Check if undo is available
29
+ */
30
+ canUndo(): boolean;
31
+ /**
32
+ * Check if redo is available
33
+ */
34
+ canRedo(): boolean;
35
+ /**
36
+ * Get description of the command that would be undone
37
+ */
38
+ getUndoDescription(): string | null;
39
+ /**
40
+ * Get description of the command that would be redone
41
+ */
42
+ getRedoDescription(): string | null;
43
+ /**
44
+ * Clear all history
45
+ */
46
+ clear(): void;
47
+ /**
48
+ * Subscribe to history changes
49
+ */
50
+ subscribe(listener: () => void): () => void;
51
+ private notifyListeners;
52
+ }
53
+ /**
54
+ * Get the singleton HistoryManager instance
55
+ */
56
+ export declare function getHistoryManager(): HistoryManager;
@@ -0,0 +1,137 @@
1
+ import { MAX_HISTORY_SIZE } from './types.js';
2
+ /**
3
+ * Manages undo/redo history using the Command Pattern
4
+ */
5
+ export class HistoryManager {
6
+ undoStack = [];
7
+ redoStack = [];
8
+ listeners = new Set();
9
+ /**
10
+ * Execute a command and add it to the undo stack
11
+ */
12
+ async execute(command) {
13
+ await command.execute();
14
+ // Add to undo stack
15
+ this.undoStack.push(command);
16
+ // Clear redo stack (new action invalidates redo history)
17
+ this.redoStack = [];
18
+ // Enforce max history size
19
+ if (this.undoStack.length > MAX_HISTORY_SIZE) {
20
+ this.undoStack.shift();
21
+ }
22
+ this.notifyListeners();
23
+ }
24
+ /**
25
+ * Undo the last command
26
+ * @returns true if undo was performed, false if nothing to undo
27
+ */
28
+ async undo() {
29
+ const command = this.undoStack.pop();
30
+ if (!command) {
31
+ return false;
32
+ }
33
+ try {
34
+ await command.undo();
35
+ this.redoStack.push(command);
36
+ this.notifyListeners();
37
+ return true;
38
+ }
39
+ catch (error) {
40
+ // Re-add command to undo stack if undo fails
41
+ this.undoStack.push(command);
42
+ throw error;
43
+ }
44
+ }
45
+ /**
46
+ * Redo the last undone command
47
+ * @returns true if redo was performed, false if nothing to redo
48
+ */
49
+ async redo() {
50
+ const command = this.redoStack.pop();
51
+ if (!command) {
52
+ return false;
53
+ }
54
+ try {
55
+ await command.execute();
56
+ this.undoStack.push(command);
57
+ this.notifyListeners();
58
+ return true;
59
+ }
60
+ catch (error) {
61
+ // Re-add command to redo stack if redo fails
62
+ this.redoStack.push(command);
63
+ throw error;
64
+ }
65
+ }
66
+ /**
67
+ * Get the current history state
68
+ */
69
+ getState() {
70
+ return {
71
+ undoCount: this.undoStack.length,
72
+ redoCount: this.redoStack.length,
73
+ lastCommandDescription: this.undoStack.length > 0
74
+ ? this.undoStack[this.undoStack.length - 1].description
75
+ : null,
76
+ };
77
+ }
78
+ /**
79
+ * Check if undo is available
80
+ */
81
+ canUndo() {
82
+ return this.undoStack.length > 0;
83
+ }
84
+ /**
85
+ * Check if redo is available
86
+ */
87
+ canRedo() {
88
+ return this.redoStack.length > 0;
89
+ }
90
+ /**
91
+ * Get description of the command that would be undone
92
+ */
93
+ getUndoDescription() {
94
+ return this.undoStack.length > 0
95
+ ? this.undoStack[this.undoStack.length - 1].description
96
+ : null;
97
+ }
98
+ /**
99
+ * Get description of the command that would be redone
100
+ */
101
+ getRedoDescription() {
102
+ return this.redoStack.length > 0
103
+ ? this.redoStack[this.redoStack.length - 1].description
104
+ : null;
105
+ }
106
+ /**
107
+ * Clear all history
108
+ */
109
+ clear() {
110
+ this.undoStack = [];
111
+ this.redoStack = [];
112
+ this.notifyListeners();
113
+ }
114
+ /**
115
+ * Subscribe to history changes
116
+ */
117
+ subscribe(listener) {
118
+ this.listeners.add(listener);
119
+ return () => this.listeners.delete(listener);
120
+ }
121
+ notifyListeners() {
122
+ for (const listener of this.listeners) {
123
+ listener();
124
+ }
125
+ }
126
+ }
127
+ // Singleton instance
128
+ let historyManagerInstance = null;
129
+ /**
130
+ * Get the singleton HistoryManager instance
131
+ */
132
+ export function getHistoryManager() {
133
+ if (!historyManagerInstance) {
134
+ historyManagerInstance = new HistoryManager();
135
+ }
136
+ return historyManagerInstance;
137
+ }
@@ -0,0 +1,19 @@
1
+ import type { UndoableCommand } from '../types.js';
2
+ import type { TaskStatus } from '../../../db/schema.js';
3
+ interface ConvertToProjectParams {
4
+ taskId: string;
5
+ originalStatus: TaskStatus;
6
+ description: string;
7
+ }
8
+ /**
9
+ * Command to convert a task to a project
10
+ */
11
+ export declare class ConvertToProjectCommand implements UndoableCommand {
12
+ readonly description: string;
13
+ private readonly taskId;
14
+ private readonly originalStatus;
15
+ constructor(params: ConvertToProjectParams);
16
+ execute(): Promise<void>;
17
+ undo(): Promise<void>;
18
+ }
19
+ export {};
@@ -0,0 +1,37 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import { getDb, schema } from '../../../db/index.js';
3
+ /**
4
+ * Command to convert a task to a project
5
+ */
6
+ export class ConvertToProjectCommand {
7
+ description;
8
+ taskId;
9
+ originalStatus;
10
+ constructor(params) {
11
+ this.taskId = params.taskId;
12
+ this.originalStatus = params.originalStatus;
13
+ this.description = params.description;
14
+ }
15
+ async execute() {
16
+ const db = getDb();
17
+ await db
18
+ .update(schema.tasks)
19
+ .set({
20
+ isProject: true,
21
+ status: 'next',
22
+ updatedAt: new Date(),
23
+ })
24
+ .where(eq(schema.tasks.id, this.taskId));
25
+ }
26
+ async undo() {
27
+ const db = getDb();
28
+ await db
29
+ .update(schema.tasks)
30
+ .set({
31
+ isProject: false,
32
+ status: this.originalStatus,
33
+ updatedAt: new Date(),
34
+ })
35
+ .where(eq(schema.tasks.id, this.taskId));
36
+ }
37
+ }
@@ -0,0 +1,18 @@
1
+ import type { UndoableCommand } from '../types.js';
2
+ import type { NewComment } from '../../../db/schema.js';
3
+ interface CreateCommentParams {
4
+ comment: NewComment;
5
+ description: string;
6
+ }
7
+ /**
8
+ * Command to create a new comment
9
+ */
10
+ export declare class CreateCommentCommand implements UndoableCommand {
11
+ readonly description: string;
12
+ private readonly comment;
13
+ private createdCommentId;
14
+ constructor(params: CreateCommentParams);
15
+ execute(): Promise<void>;
16
+ undo(): Promise<void>;
17
+ }
18
+ export {};
@@ -0,0 +1,23 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import { getDb, schema } from '../../../db/index.js';
3
+ /**
4
+ * Command to create a new comment
5
+ */
6
+ export class CreateCommentCommand {
7
+ description;
8
+ comment;
9
+ createdCommentId;
10
+ constructor(params) {
11
+ this.comment = params.comment;
12
+ this.description = params.description;
13
+ this.createdCommentId = params.comment.id;
14
+ }
15
+ async execute() {
16
+ const db = getDb();
17
+ await db.insert(schema.comments).values(this.comment);
18
+ }
19
+ async undo() {
20
+ const db = getDb();
21
+ await db.delete(schema.comments).where(eq(schema.comments.id, this.createdCommentId));
22
+ }
23
+ }
@@ -0,0 +1,18 @@
1
+ import type { UndoableCommand } from '../types.js';
2
+ import type { NewTask } from '../../../db/schema.js';
3
+ interface CreateTaskParams {
4
+ task: NewTask;
5
+ description: string;
6
+ }
7
+ /**
8
+ * Command to create a new task
9
+ */
10
+ export declare class CreateTaskCommand implements UndoableCommand {
11
+ readonly description: string;
12
+ private readonly task;
13
+ private createdTaskId;
14
+ constructor(params: CreateTaskParams);
15
+ execute(): Promise<void>;
16
+ undo(): Promise<void>;
17
+ }
18
+ export {};