epoch-tui 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.
- package/dist/App.d.ts +3 -0
- package/dist/App.js +38 -0
- package/dist/components/calendar/CalendarPane.d.ts +2 -0
- package/dist/components/calendar/CalendarPane.js +91 -0
- package/dist/components/calendar/DayCell.d.ts +7 -0
- package/dist/components/calendar/DayCell.js +26 -0
- package/dist/components/calendar/MonthView.d.ts +7 -0
- package/dist/components/calendar/MonthView.js +10 -0
- package/dist/components/common/BorderedBox.d.ts +18 -0
- package/dist/components/common/BorderedBox.js +29 -0
- package/dist/components/common/ClearTimelineDialog.d.ts +2 -0
- package/dist/components/common/ClearTimelineDialog.js +33 -0
- package/dist/components/common/FullscreenBackground.d.ts +11 -0
- package/dist/components/common/FullscreenBackground.js +12 -0
- package/dist/components/common/HelpDialog.d.ts +2 -0
- package/dist/components/common/HelpDialog.js +40 -0
- package/dist/components/common/Modal.d.ts +10 -0
- package/dist/components/common/Modal.js +15 -0
- package/dist/components/common/Separator.d.ts +8 -0
- package/dist/components/common/Separator.js +10 -0
- package/dist/components/common/ThemeDialog.d.ts +2 -0
- package/dist/components/common/ThemeDialog.js +117 -0
- package/dist/components/common/ThemedScreen.d.ts +22 -0
- package/dist/components/common/ThemedScreen.js +36 -0
- package/dist/components/common/ThemedText.d.ts +11 -0
- package/dist/components/common/ThemedText.js +20 -0
- package/dist/components/layout/Pane.d.ts +12 -0
- package/dist/components/layout/Pane.js +10 -0
- package/dist/components/layout/ThreeColumnLayout.d.ts +13 -0
- package/dist/components/layout/ThreeColumnLayout.js +22 -0
- package/dist/components/overview/OverviewScreen.d.ts +2 -0
- package/dist/components/overview/OverviewScreen.js +138 -0
- package/dist/components/tasks/TaskHeader.d.ts +7 -0
- package/dist/components/tasks/TaskHeader.js +8 -0
- package/dist/components/tasks/TaskItem.d.ts +10 -0
- package/dist/components/tasks/TaskItem.js +25 -0
- package/dist/components/tasks/TaskList.d.ts +11 -0
- package/dist/components/tasks/TaskList.js +11 -0
- package/dist/components/tasks/TasksPane.d.ts +2 -0
- package/dist/components/tasks/TasksPane.js +410 -0
- package/dist/components/timeline/TimelineEntry.d.ts +9 -0
- package/dist/components/timeline/TimelineEntry.js +26 -0
- package/dist/components/timeline/TimelinePane.d.ts +2 -0
- package/dist/components/timeline/TimelinePane.js +78 -0
- package/dist/contexts/AppContext.d.ts +47 -0
- package/dist/contexts/AppContext.js +104 -0
- package/dist/contexts/StorageContext.d.ts +15 -0
- package/dist/contexts/StorageContext.js +83 -0
- package/dist/contexts/ThemeContext.d.ts +15 -0
- package/dist/contexts/ThemeContext.js +44 -0
- package/dist/hooks/useKeyboardNav.d.ts +1 -0
- package/dist/hooks/useKeyboardNav.js +89 -0
- package/dist/hooks/useTerminalSize.d.ts +9 -0
- package/dist/hooks/useTerminalSize.js +34 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8 -0
- package/dist/services/calendarService.d.ts +18 -0
- package/dist/services/calendarService.js +57 -0
- package/dist/services/storage.d.ts +14 -0
- package/dist/services/storage.js +130 -0
- package/dist/services/taskService.d.ts +17 -0
- package/dist/services/taskService.js +106 -0
- package/dist/services/timelineService.d.ts +25 -0
- package/dist/services/timelineService.js +78 -0
- package/dist/themes/amazon.d.ts +2 -0
- package/dist/themes/amazon.js +37 -0
- package/dist/themes/amazonLight.d.ts +2 -0
- package/dist/themes/amazonLight.js +38 -0
- package/dist/themes/apple.d.ts +2 -0
- package/dist/themes/apple.js +38 -0
- package/dist/themes/appleLight.d.ts +2 -0
- package/dist/themes/appleLight.js +38 -0
- package/dist/themes/atomOneDark.d.ts +2 -0
- package/dist/themes/atomOneDark.js +37 -0
- package/dist/themes/atomOneLight.d.ts +2 -0
- package/dist/themes/atomOneLight.js +38 -0
- package/dist/themes/batman.d.ts +2 -0
- package/dist/themes/batman.js +37 -0
- package/dist/themes/catppuccin.d.ts +2 -0
- package/dist/themes/catppuccin.js +37 -0
- package/dist/themes/catppuccinLatte.d.ts +2 -0
- package/dist/themes/catppuccinLatte.js +38 -0
- package/dist/themes/claude.d.ts +2 -0
- package/dist/themes/claude.js +48 -0
- package/dist/themes/claudeCode.d.ts +2 -0
- package/dist/themes/claudeCode.js +47 -0
- package/dist/themes/cursor.d.ts +2 -0
- package/dist/themes/cursor.js +38 -0
- package/dist/themes/cursorLight.d.ts +2 -0
- package/dist/themes/cursorLight.js +38 -0
- package/dist/themes/dark.d.ts +2 -0
- package/dist/themes/dark.js +38 -0
- package/dist/themes/githubDark.d.ts +2 -0
- package/dist/themes/githubDark.js +37 -0
- package/dist/themes/githubLight.d.ts +2 -0
- package/dist/themes/githubLight.js +38 -0
- package/dist/themes/index.d.ts +9 -0
- package/dist/themes/index.js +83 -0
- package/dist/themes/instagram.d.ts +2 -0
- package/dist/themes/instagram.js +37 -0
- package/dist/themes/instagramLight.d.ts +2 -0
- package/dist/themes/instagramLight.js +38 -0
- package/dist/themes/intellij.d.ts +2 -0
- package/dist/themes/intellij.js +37 -0
- package/dist/themes/intellijLight.d.ts +2 -0
- package/dist/themes/intellijLight.js +38 -0
- package/dist/themes/light.d.ts +2 -0
- package/dist/themes/light.js +38 -0
- package/dist/themes/nord.d.ts +2 -0
- package/dist/themes/nord.js +37 -0
- package/dist/themes/nordLight.d.ts +2 -0
- package/dist/themes/nordLight.js +38 -0
- package/dist/themes/postman.d.ts +2 -0
- package/dist/themes/postman.js +37 -0
- package/dist/themes/postmanLight.d.ts +2 -0
- package/dist/themes/postmanLight.js +38 -0
- package/dist/themes/spiderman.d.ts +2 -0
- package/dist/themes/spiderman.js +37 -0
- package/dist/themes/terminal.d.ts +2 -0
- package/dist/themes/terminal.js +37 -0
- package/dist/themes/ubuntu.d.ts +2 -0
- package/dist/themes/ubuntu.js +37 -0
- package/dist/themes/ubuntuLight.d.ts +2 -0
- package/dist/themes/ubuntuLight.js +38 -0
- package/dist/themes/x.d.ts +2 -0
- package/dist/themes/x.js +38 -0
- package/dist/themes/xLight.d.ts +2 -0
- package/dist/themes/xLight.js +38 -0
- package/dist/types/calendar.d.ts +19 -0
- package/dist/types/calendar.js +1 -0
- package/dist/types/storage.d.ts +21 -0
- package/dist/types/storage.js +1 -0
- package/dist/types/task.d.ts +21 -0
- package/dist/types/task.js +1 -0
- package/dist/types/theme.d.ts +38 -0
- package/dist/types/theme.js +1 -0
- package/dist/types/timeline.d.ts +23 -0
- package/dist/types/timeline.js +9 -0
- package/dist/utils/date.d.ts +7 -0
- package/dist/utils/date.js +37 -0
- package/dist/utils/logger.d.ts +3 -0
- package/dist/utils/logger.js +39 -0
- package/dist/utils/tree.d.ts +11 -0
- package/dist/utils/tree.js +64 -0
- package/dist/utils/validation.d.ts +7 -0
- package/dist/utils/validation.js +35 -0
- package/package.json +44 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useMemo, useRef } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { TextInput } from "@inkjs/ui";
|
|
5
|
+
import { useTheme } from "../../contexts/ThemeContext";
|
|
6
|
+
import { useApp } from "../../contexts/AppContext";
|
|
7
|
+
import { Pane } from "../layout/Pane";
|
|
8
|
+
import { TaskHeader } from "./TaskHeader";
|
|
9
|
+
import { getDateString } from "../../utils/date";
|
|
10
|
+
import { taskService } from "../../services/taskService";
|
|
11
|
+
import { timelineService } from "../../services/timelineService";
|
|
12
|
+
import { TimelineEventType } from "../../types/timeline";
|
|
13
|
+
import { useTerminalSize } from "../../hooks/useTerminalSize";
|
|
14
|
+
export const TasksPane = () => {
|
|
15
|
+
const { tasks, setTasks, timeline, setTimeline, activePane, selectedDate, isInputMode, setIsInputMode, isModalOpen, } = useApp();
|
|
16
|
+
const { theme } = useTheme();
|
|
17
|
+
const [expandedIds, setExpandedIds] = useState(new Set());
|
|
18
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
19
|
+
const [editMode, setEditMode] = useState("none");
|
|
20
|
+
const [editValue, setEditValue] = useState("");
|
|
21
|
+
const [parentTaskId, setParentTaskId] = useState(null);
|
|
22
|
+
const inputValueRef = useRef(""); // Track current input value for uncontrolled TextInput
|
|
23
|
+
const [inputKey, setInputKey] = useState(0); // Key to force TextInput remount
|
|
24
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
25
|
+
const { height: terminalHeight } = useTerminalSize();
|
|
26
|
+
const visibleRows = useMemo(() => {
|
|
27
|
+
// Header/Stats (4) + Keyboard Hints (3) + Padding/Margins (4) = ~11 lines
|
|
28
|
+
return Math.max(5, terminalHeight - 11);
|
|
29
|
+
}, [terminalHeight]);
|
|
30
|
+
const dateStr = getDateString(new Date(selectedDate.year, selectedDate.month, selectedDate.day));
|
|
31
|
+
const dayTasks = tasks[dateStr] || [];
|
|
32
|
+
const stats = taskService.getTaskStats(tasks, dateStr);
|
|
33
|
+
const isFocused = activePane === "tasks" && !isModalOpen;
|
|
34
|
+
// Flatten tasks for navigation (only visible ones based on expanded state)
|
|
35
|
+
const flatTasks = useMemo(() => {
|
|
36
|
+
const result = [];
|
|
37
|
+
const traverse = (taskList, depth) => {
|
|
38
|
+
for (const task of taskList) {
|
|
39
|
+
result.push({ task, depth });
|
|
40
|
+
if (task.children.length > 0 && expandedIds.has(task.id)) {
|
|
41
|
+
traverse(task.children, depth + 1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
traverse(dayTasks, 0);
|
|
46
|
+
return result;
|
|
47
|
+
}, [dayTasks, expandedIds]);
|
|
48
|
+
const selectedTask = flatTasks[selectedIndex]?.task;
|
|
49
|
+
const selectedTaskId = selectedTask?.id;
|
|
50
|
+
// Reset selection when day changes
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
setSelectedIndex(0);
|
|
53
|
+
setEditMode("none");
|
|
54
|
+
}, [dateStr]);
|
|
55
|
+
// Clamp selection index when tasks change
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (selectedIndex >= flatTasks.length && flatTasks.length > 0) {
|
|
58
|
+
setSelectedIndex(flatTasks.length - 1);
|
|
59
|
+
}
|
|
60
|
+
}, [flatTasks.length, selectedIndex]);
|
|
61
|
+
// Keep selected task in view
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (selectedIndex < scrollOffset) {
|
|
64
|
+
setScrollOffset(selectedIndex);
|
|
65
|
+
}
|
|
66
|
+
else if (selectedIndex >= scrollOffset + visibleRows) {
|
|
67
|
+
setScrollOffset(selectedIndex - visibleRows + 1);
|
|
68
|
+
}
|
|
69
|
+
}, [selectedIndex, visibleRows, scrollOffset]);
|
|
70
|
+
// Reset scroll when day changes
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
setScrollOffset(0);
|
|
73
|
+
}, [dateStr]);
|
|
74
|
+
const handleAddTask = () => {
|
|
75
|
+
inputValueRef.current = "";
|
|
76
|
+
setEditMode("add");
|
|
77
|
+
setEditValue("");
|
|
78
|
+
setInputKey((k) => k + 1);
|
|
79
|
+
setIsInputMode(true);
|
|
80
|
+
};
|
|
81
|
+
const handleEditTask = () => {
|
|
82
|
+
if (selectedTask) {
|
|
83
|
+
inputValueRef.current = selectedTask.title;
|
|
84
|
+
setEditMode("edit");
|
|
85
|
+
setEditValue(selectedTask.title);
|
|
86
|
+
setInputKey((k) => k + 1);
|
|
87
|
+
setIsInputMode(true);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const handleAddSubtask = () => {
|
|
91
|
+
if (selectedTask) {
|
|
92
|
+
inputValueRef.current = "";
|
|
93
|
+
setEditMode("addSubtask");
|
|
94
|
+
setEditValue("");
|
|
95
|
+
setParentTaskId(selectedTask.id);
|
|
96
|
+
setInputKey((k) => k + 1);
|
|
97
|
+
setIsInputMode(true);
|
|
98
|
+
setExpandedIds((prev) => new Set(prev).add(selectedTask.id));
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const handleDeleteTask = () => {
|
|
102
|
+
if (selectedTaskId) {
|
|
103
|
+
try {
|
|
104
|
+
const updated = taskService.deleteTask(tasks, selectedTaskId);
|
|
105
|
+
setTasks(updated);
|
|
106
|
+
// Remove all timeline events for this task
|
|
107
|
+
const updatedTimeline = timelineService.removeEventsByTaskId(timeline, selectedTaskId);
|
|
108
|
+
setTimeline(updatedTimeline);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
console.error("Error deleting task:", err);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const handleChangeState = (newState) => {
|
|
116
|
+
if (selectedTaskId && selectedTask) {
|
|
117
|
+
try {
|
|
118
|
+
const previousState = selectedTask.state;
|
|
119
|
+
const updated = taskService.changeTaskState(tasks, selectedTaskId, newState);
|
|
120
|
+
setTasks(updated);
|
|
121
|
+
// Handle timeline based on state change
|
|
122
|
+
if (newState === "todo") {
|
|
123
|
+
// If toggling back to todo, remove the previous state event
|
|
124
|
+
const eventTypeToRemove = {
|
|
125
|
+
todo: TimelineEventType.STARTED, // shouldn't happen
|
|
126
|
+
completed: TimelineEventType.COMPLETED,
|
|
127
|
+
delegated: TimelineEventType.DELEGATED,
|
|
128
|
+
delayed: TimelineEventType.DELAYED,
|
|
129
|
+
};
|
|
130
|
+
const updatedTimeline = timelineService.removeLastEventByType(timeline, selectedTaskId, eventTypeToRemove[previousState]);
|
|
131
|
+
setTimeline(updatedTimeline);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
// Create timeline event for state change
|
|
135
|
+
const eventTypeMap = {
|
|
136
|
+
todo: TimelineEventType.STARTED, // shouldn't happen
|
|
137
|
+
completed: TimelineEventType.COMPLETED,
|
|
138
|
+
delegated: TimelineEventType.DELEGATED,
|
|
139
|
+
delayed: TimelineEventType.DELAYED,
|
|
140
|
+
};
|
|
141
|
+
const event = timelineService.createEvent(selectedTaskId, selectedTask.title, eventTypeMap[newState], new Date(), previousState, newState);
|
|
142
|
+
const updatedTimeline = timelineService.addEvent(timeline, event);
|
|
143
|
+
setTimeline(updatedTimeline);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
console.error("Error changing task state:", err);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const handleToggleComplete = () => {
|
|
152
|
+
if (selectedTask) {
|
|
153
|
+
const newState = selectedTask.state === "completed" ? "todo" : "completed";
|
|
154
|
+
handleChangeState(newState);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const handleSubmitEdit = (value) => {
|
|
158
|
+
const trimmed = value.trim();
|
|
159
|
+
if (!trimmed) {
|
|
160
|
+
setEditMode("none");
|
|
161
|
+
setEditValue("");
|
|
162
|
+
setParentTaskId(null);
|
|
163
|
+
setIsInputMode(false);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
if (editMode === "add") {
|
|
168
|
+
const newTask = taskService.createTask(trimmed, dateStr);
|
|
169
|
+
const newTasks = {
|
|
170
|
+
...tasks,
|
|
171
|
+
[dateStr]: [...dayTasks, newTask],
|
|
172
|
+
};
|
|
173
|
+
setTasks(newTasks);
|
|
174
|
+
setSelectedIndex(flatTasks.length); // Select newly added task
|
|
175
|
+
// No timeline event for task creation - only track started/completed/etc
|
|
176
|
+
}
|
|
177
|
+
else if (editMode === "addSubtask" && parentTaskId) {
|
|
178
|
+
const updated = taskService.addSubtask(tasks, parentTaskId, trimmed);
|
|
179
|
+
setTasks(updated);
|
|
180
|
+
// Find and select the newly added subtask
|
|
181
|
+
const parentIndex = flatTasks.findIndex((ft) => ft.task.id === parentTaskId);
|
|
182
|
+
if (parentIndex !== -1) {
|
|
183
|
+
setSelectedIndex(parentIndex + 1); // Select first child (newly added)
|
|
184
|
+
}
|
|
185
|
+
// No timeline event for subtask creation
|
|
186
|
+
}
|
|
187
|
+
else if (editMode === "edit" && selectedTaskId) {
|
|
188
|
+
const updated = taskService.updateTask(tasks, selectedTaskId, {
|
|
189
|
+
title: trimmed,
|
|
190
|
+
});
|
|
191
|
+
setTasks(updated);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
console.error("Error saving task:", err);
|
|
196
|
+
}
|
|
197
|
+
setEditMode("none");
|
|
198
|
+
setEditValue("");
|
|
199
|
+
setParentTaskId(null);
|
|
200
|
+
setIsInputMode(false);
|
|
201
|
+
};
|
|
202
|
+
const handleCancelEdit = () => {
|
|
203
|
+
setEditMode("none");
|
|
204
|
+
setEditValue("");
|
|
205
|
+
setParentTaskId(null);
|
|
206
|
+
setIsInputMode(false);
|
|
207
|
+
};
|
|
208
|
+
const handleToggleExpand = () => {
|
|
209
|
+
if (selectedTaskId && selectedTask?.children.length > 0) {
|
|
210
|
+
setExpandedIds((prev) => {
|
|
211
|
+
const newSet = new Set(prev);
|
|
212
|
+
if (newSet.has(selectedTaskId)) {
|
|
213
|
+
newSet.delete(selectedTaskId);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
newSet.add(selectedTaskId);
|
|
217
|
+
}
|
|
218
|
+
return newSet;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const handleExpand = () => {
|
|
223
|
+
if (selectedTaskId && selectedTask?.children.length > 0) {
|
|
224
|
+
setExpandedIds((prev) => new Set(prev).add(selectedTaskId));
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
const handleCollapse = () => {
|
|
228
|
+
if (selectedTaskId && selectedTask?.children.length > 0) {
|
|
229
|
+
setExpandedIds((prev) => {
|
|
230
|
+
const newSet = new Set(prev);
|
|
231
|
+
newSet.delete(selectedTaskId);
|
|
232
|
+
return newSet;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const handleExpandAll = () => {
|
|
237
|
+
// Get all task IDs that have children
|
|
238
|
+
const allParentIds = new Set();
|
|
239
|
+
const collectParents = (taskList) => {
|
|
240
|
+
for (const task of taskList) {
|
|
241
|
+
if (task.children.length > 0) {
|
|
242
|
+
allParentIds.add(task.id);
|
|
243
|
+
collectParents(task.children);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
collectParents(dayTasks);
|
|
248
|
+
setExpandedIds(allParentIds);
|
|
249
|
+
};
|
|
250
|
+
const handleCollapseAll = () => {
|
|
251
|
+
setExpandedIds(new Set());
|
|
252
|
+
};
|
|
253
|
+
// Handle escape key to cancel editing
|
|
254
|
+
useInput((_input, key) => {
|
|
255
|
+
if (key.escape) {
|
|
256
|
+
handleCancelEdit();
|
|
257
|
+
}
|
|
258
|
+
}, { isActive: isFocused && editMode !== "none" });
|
|
259
|
+
// Input handler for navigation/command mode
|
|
260
|
+
useInput((input, key) => {
|
|
261
|
+
// Expand/Collapse ALL with Cmd/Ctrl + arrow keys
|
|
262
|
+
// Note: On Mac terminals, Cmd+Left sends Ctrl+a and Cmd+Right sends Ctrl+e
|
|
263
|
+
if ((key.meta || key.ctrl) && (input === "a" || key.leftArrow)) {
|
|
264
|
+
handleCollapseAll();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if ((key.meta || key.ctrl) && (input === "e" || key.rightArrow)) {
|
|
268
|
+
handleExpandAll();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// Navigation
|
|
272
|
+
if (input === "j" || key.downArrow) {
|
|
273
|
+
setSelectedIndex((prev) => Math.min(prev + 1, flatTasks.length - 1));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (input === "k" || key.upArrow) {
|
|
277
|
+
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// Expand/Collapse current with arrow keys (no modifiers)
|
|
281
|
+
if (key.leftArrow && !key.meta && !key.ctrl) {
|
|
282
|
+
handleCollapse();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (key.rightArrow && !key.meta && !key.ctrl) {
|
|
286
|
+
handleExpand();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// Task actions (only if no modifier keys are pressed)
|
|
290
|
+
if (input === "a" && !key.meta && !key.ctrl && !key.shift) {
|
|
291
|
+
handleAddTask();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (input === "e" &&
|
|
295
|
+
selectedTask &&
|
|
296
|
+
!key.meta &&
|
|
297
|
+
!key.ctrl &&
|
|
298
|
+
!key.shift) {
|
|
299
|
+
handleEditTask();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (input === "d" && selectedTask) {
|
|
303
|
+
handleDeleteTask();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (input === " " && selectedTask) {
|
|
307
|
+
handleToggleComplete();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (input === "D" && selectedTask) {
|
|
311
|
+
handleChangeState("delegated");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (input === "x" && selectedTask) {
|
|
315
|
+
// Toggle delayed state
|
|
316
|
+
if (selectedTask.state === "delayed") {
|
|
317
|
+
handleChangeState("todo");
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
handleChangeState("delayed");
|
|
321
|
+
}
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (input === "s" && selectedTask) {
|
|
325
|
+
try {
|
|
326
|
+
// Toggle start - if already started (has startTime but no endTime), unstart it
|
|
327
|
+
if (selectedTask.startTime && !selectedTask.endTime) {
|
|
328
|
+
// Unstart: clear startTime and remove timeline event
|
|
329
|
+
const updated = taskService.updateTask(tasks, selectedTaskId, {
|
|
330
|
+
startTime: undefined,
|
|
331
|
+
});
|
|
332
|
+
setTasks(updated);
|
|
333
|
+
// Remove the started event from timeline
|
|
334
|
+
const updatedTimeline = timelineService.removeLastEventByType(timeline, selectedTaskId, TimelineEventType.STARTED);
|
|
335
|
+
setTimeline(updatedTimeline);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
// Start task
|
|
339
|
+
const updated = taskService.startTask(tasks, selectedTaskId);
|
|
340
|
+
setTasks(updated);
|
|
341
|
+
// Create timeline event for starting task
|
|
342
|
+
const event = timelineService.createEvent(selectedTaskId, selectedTask.title, TimelineEventType.STARTED, new Date());
|
|
343
|
+
const updatedTimeline = timelineService.addEvent(timeline, event);
|
|
344
|
+
setTimeline(updatedTimeline);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
console.error("Error toggling task start:", err);
|
|
349
|
+
}
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (key.return && selectedTask) {
|
|
353
|
+
handleToggleExpand();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (key.tab && selectedTask) {
|
|
357
|
+
handleAddSubtask();
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
}, { isActive: isFocused && editMode === "none" });
|
|
361
|
+
return (_jsx(Pane, { title: "Tasks", isFocused: isFocused, children: _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(TaskHeader, { selectedDate: new Date(selectedDate.year, selectedDate.month, selectedDate.day), completionPercentage: stats.percentage }), editMode === "add" && (_jsxs(Box, { marginY: 1, children: [_jsx(Text, { color: theme.colors.focusIndicator, children: "> " }), _jsx(TextInput, { defaultValue: "", placeholder: "Enter task name...", onChange: (val) => {
|
|
362
|
+
inputValueRef.current = val;
|
|
363
|
+
}, onSubmit: handleSubmitEdit }, `add-${inputKey}`)] })), editMode === "addSubtask" && (_jsxs(Box, { marginY: 1, children: [_jsx(Text, { color: theme.colors.focusIndicator, children: "> " }), _jsx(Text, { color: theme.colors.keyboardHint, children: " " }), _jsx(TextInput, { defaultValue: "", placeholder: "Enter subtask name...", onChange: (val) => {
|
|
364
|
+
inputValueRef.current = val;
|
|
365
|
+
}, onSubmit: handleSubmitEdit }, `subtask-${inputKey}`)] })), dayTasks.length === 0 &&
|
|
366
|
+
editMode !== "add" &&
|
|
367
|
+
editMode !== "addSubtask" ? (_jsx(Box, { marginY: 1, children: _jsx(Text, { color: theme.colors.keyboardHint, dimColor: true, children: "No tasks. Press 'a' to add one." }) })) : (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingRight: 2, children: [scrollOffset > 0 && (_jsx(Box, { justifyContent: "center", marginBottom: 1, children: _jsx(Text, { color: theme.colors.keyboardHint, dimColor: true, children: "-- more above --" }) })), flatTasks
|
|
368
|
+
.slice(scrollOffset, scrollOffset + visibleRows)
|
|
369
|
+
.map(({ task, depth }, sliceIndex) => {
|
|
370
|
+
const index = scrollOffset + sliceIndex;
|
|
371
|
+
const isSelected = index === selectedIndex;
|
|
372
|
+
const isExpanded = expandedIds.has(task.id);
|
|
373
|
+
const isEditing = editMode === "edit" && isSelected;
|
|
374
|
+
if (isEditing) {
|
|
375
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: theme.colors.focusIndicator, children: "> " }), _jsx(TextInput, { defaultValue: editValue, onChange: (val) => {
|
|
376
|
+
inputValueRef.current = val;
|
|
377
|
+
}, onSubmit: handleSubmitEdit }, `edit-${inputKey}`)] }, task.id));
|
|
378
|
+
}
|
|
379
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: isSelected
|
|
380
|
+
? theme.colors.focusIndicator
|
|
381
|
+
: theme.colors.foreground, children: isSelected ? ">" : " " }), _jsx(Text, { children: " " }), _jsx(Text, { children: " ".repeat(depth) }), _jsx(Text, { color: isSelected
|
|
382
|
+
? theme.colors.focusIndicator
|
|
383
|
+
: getStateColor(task.state, theme), children: getCheckbox(task.state) }), _jsx(Text, { children: " " }), task.children.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: isExpanded ? "▼" : "▶" }), _jsx(Text, { children: " " })] })), _jsx(Text, { color: isSelected
|
|
384
|
+
? theme.colors.focusIndicator
|
|
385
|
+
: getStateColor(task.state, theme), strikethrough: task.state === "completed", dimColor: task.state === "delayed" && !isSelected, children: task.title }), task.startTime && !task.endTime && (_jsxs(Text, { color: isSelected
|
|
386
|
+
? theme.colors.focusIndicator
|
|
387
|
+
: theme.colors.timelineEventStarted, children: [" ", "\u25B6"] }))] }, task.id));
|
|
388
|
+
}), scrollOffset + visibleRows < flatTasks.length && (_jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: theme.colors.keyboardHint, dimColor: true, children: "-- more below --" }) }))] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.colors.keyboardHint, dimColor: true, children: "j/k: navigate a: add Tab: add subtask e: edit d: delete" }), _jsx(Text, { color: theme.colors.keyboardHint, dimColor: true, children: "Space: complete D: delegate x: delay s: start" }), _jsx(Text, { color: theme.colors.keyboardHint, dimColor: true, children: "\u2190/\u2192: collapse/expand Cmd+\u2190/\u2192: collapse/expand all" })] })] }) }));
|
|
389
|
+
};
|
|
390
|
+
function getCheckbox(state) {
|
|
391
|
+
switch (state) {
|
|
392
|
+
case "completed":
|
|
393
|
+
return "[✓]";
|
|
394
|
+
case "delegated":
|
|
395
|
+
return "[→]";
|
|
396
|
+
case "delayed":
|
|
397
|
+
return "[‖]";
|
|
398
|
+
default:
|
|
399
|
+
return "[ ]";
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function getStateColor(state, theme) {
|
|
403
|
+
const colors = {
|
|
404
|
+
todo: theme.colors.taskStateTodo,
|
|
405
|
+
completed: theme.colors.taskStateCompleted,
|
|
406
|
+
delegated: theme.colors.taskStateDelegated,
|
|
407
|
+
delayed: theme.colors.taskStateDelayed,
|
|
408
|
+
};
|
|
409
|
+
return colors[state] || theme.colors.foreground;
|
|
410
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { TimelineEvent } from "../../types/timeline";
|
|
3
|
+
interface TimelineEntryProps {
|
|
4
|
+
event: TimelineEvent;
|
|
5
|
+
isLast: boolean;
|
|
6
|
+
hasNextSameTask: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare const TimelineEntry: React.FC<TimelineEntryProps>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { useTheme } from "../../contexts/ThemeContext";
|
|
4
|
+
export const TimelineEntry = ({ event, isLast, hasNextSameTask, }) => {
|
|
5
|
+
const { theme } = useTheme();
|
|
6
|
+
// Format time as "7:31 am"
|
|
7
|
+
const hours = event.timestamp.getHours();
|
|
8
|
+
const minutes = event.timestamp.getMinutes();
|
|
9
|
+
const ampm = hours >= 12 ? "pm" : "am";
|
|
10
|
+
const hour12 = hours % 12 || 12;
|
|
11
|
+
const timeStr = `${hour12}:${minutes.toString().padStart(2, "0")} ${ampm}`;
|
|
12
|
+
const typeStr = event.type.charAt(0).toUpperCase() + event.type.slice(1);
|
|
13
|
+
// Color mapping for event types
|
|
14
|
+
const eventTypeColors = {
|
|
15
|
+
created: theme.colors.foreground,
|
|
16
|
+
started: theme.colors.foreground,
|
|
17
|
+
completed: theme.colors.taskStateCompleted,
|
|
18
|
+
delegated: theme.colors.taskStateDelegated,
|
|
19
|
+
delayed: theme.colors.taskStateDelayed,
|
|
20
|
+
updated: theme.colors.foreground,
|
|
21
|
+
};
|
|
22
|
+
const color = eventTypeColors[event.type] || theme.colors.foreground;
|
|
23
|
+
// Circle style: hollow for started, filled for completed/delegated/delayed
|
|
24
|
+
const isFilledCircle = ["completed", "delegated", "delayed"].includes(event.type);
|
|
25
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: 3, justifyContent: "center", children: _jsx(Text, { color: color, children: isFilledCircle ? "●" : "○" }) }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Box, { children: _jsxs(Text, { children: [_jsxs(Text, { color: color, bold: true, children: [typeStr, ":"] }), _jsxs(Text, { color: theme.colors.foreground, children: [" ", event.taskTitle] })] }) }), _jsx(Box, { children: _jsx(Text, { color: theme.colors.keyboardHint, dimColor: true, children: timeStr }) })] })] }), !isLast && (_jsx(Box, { children: _jsx(Box, { width: 3, justifyContent: "center", children: _jsx(Text, { color: hasNextSameTask ? color : theme.colors.border, children: "\u2502" }) }) }))] }));
|
|
26
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useMemo } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { useTheme } from "../../contexts/ThemeContext";
|
|
5
|
+
import { useApp } from "../../contexts/AppContext";
|
|
6
|
+
import { Pane } from "../layout/Pane";
|
|
7
|
+
import { TimelineEntry } from "./TimelineEntry";
|
|
8
|
+
import { getDateString } from "../../utils/date";
|
|
9
|
+
import { useTerminalSize } from "../../hooks/useTerminalSize";
|
|
10
|
+
export const TimelinePane = () => {
|
|
11
|
+
const { theme } = useTheme();
|
|
12
|
+
const { selectedDate, timeline, activePane, isModalOpen, isInputMode } = useApp();
|
|
13
|
+
const isFocused = activePane === "timeline" && !isModalOpen;
|
|
14
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
15
|
+
const { height: terminalHeight } = useTerminalSize();
|
|
16
|
+
const visibleRows = useMemo(() => {
|
|
17
|
+
// Pane header/footer and padding takes some space
|
|
18
|
+
return Math.max(5, terminalHeight - 10);
|
|
19
|
+
}, [terminalHeight]);
|
|
20
|
+
const dateStr = getDateString(new Date(selectedDate.year, selectedDate.month, selectedDate.day));
|
|
21
|
+
const dayEvents = timeline[dateStr] || [];
|
|
22
|
+
// Group events by task, then sort by timestamp within each task group
|
|
23
|
+
const sortedEvents = useMemo(() => {
|
|
24
|
+
// First, group events by taskId
|
|
25
|
+
const eventsByTask = new Map();
|
|
26
|
+
dayEvents.forEach((event) => {
|
|
27
|
+
const taskEvents = eventsByTask.get(event.taskId) || [];
|
|
28
|
+
taskEvents.push(event);
|
|
29
|
+
eventsByTask.set(event.taskId, taskEvents);
|
|
30
|
+
});
|
|
31
|
+
// Sort events within each task group by timestamp
|
|
32
|
+
eventsByTask.forEach((events) => {
|
|
33
|
+
events.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
34
|
+
});
|
|
35
|
+
// Convert to array of task groups and sort by earliest event timestamp
|
|
36
|
+
const taskGroups = Array.from(eventsByTask.entries()).sort(([, eventsA], [, eventsB]) => {
|
|
37
|
+
const earliestA = eventsA[0]?.timestamp.getTime() || 0;
|
|
38
|
+
const earliestB = eventsB[0]?.timestamp.getTime() || 0;
|
|
39
|
+
return earliestA - earliestB;
|
|
40
|
+
});
|
|
41
|
+
// Flatten back to a single array
|
|
42
|
+
return taskGroups.flatMap(([, events]) => events);
|
|
43
|
+
}, [dayEvents]);
|
|
44
|
+
// Reset scroll when date changes
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
setScrollOffset(0);
|
|
47
|
+
}, [dateStr]);
|
|
48
|
+
// Scroll with j/k when focused
|
|
49
|
+
useInput((input, key) => {
|
|
50
|
+
if (!isFocused)
|
|
51
|
+
return;
|
|
52
|
+
if (input === "j" || key.downArrow) {
|
|
53
|
+
setScrollOffset((prev) => Math.min(prev + 1, Math.max(0, sortedEvents.length - visibleRows)));
|
|
54
|
+
}
|
|
55
|
+
if (input === "k" || key.upArrow) {
|
|
56
|
+
setScrollOffset((prev) => Math.max(prev - 1, 0));
|
|
57
|
+
}
|
|
58
|
+
// Page down/up
|
|
59
|
+
if (input === "d" && key.ctrl) {
|
|
60
|
+
setScrollOffset((prev) => Math.min(prev + Math.floor(visibleRows / 2), Math.max(0, sortedEvents.length - visibleRows)));
|
|
61
|
+
}
|
|
62
|
+
if (input === "u" && key.ctrl) {
|
|
63
|
+
setScrollOffset((prev) => Math.max(prev - Math.floor(visibleRows / 2), 0));
|
|
64
|
+
}
|
|
65
|
+
}, { isActive: isFocused && !isInputMode });
|
|
66
|
+
// Get visible events based on scroll
|
|
67
|
+
const visibleEvents = sortedEvents.slice(scrollOffset, scrollOffset + visibleRows);
|
|
68
|
+
// Check if scrolling is possible
|
|
69
|
+
const canScrollUp = scrollOffset > 0;
|
|
70
|
+
const canScrollDown = scrollOffset + visibleRows < sortedEvents.length;
|
|
71
|
+
return (_jsx(Pane, { title: "Timeline", isFocused: isFocused, children: _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [sortedEvents.length === 0 ? (_jsxs(Box, { marginY: 1, flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: theme.colors.keyboardHint, dimColor: true, children: "No activities yet." }), _jsx(Text, { color: theme.colors.keyboardHint, dimColor: true, children: "Press 's' to start a task." })] })) : (_jsxs(Box, { flexDirection: "column", children: [canScrollUp && (_jsx(Box, { justifyContent: "center", marginBottom: 1, children: _jsx(Text, { color: theme.colors.keyboardHint, dimColor: true, children: "-- more above --" }) })), _jsx(Box, { flexDirection: "column", children: visibleEvents.map((event, index) => {
|
|
72
|
+
const globalIndex = scrollOffset + index;
|
|
73
|
+
const isLast = globalIndex === sortedEvents.length - 1;
|
|
74
|
+
const nextEvent = sortedEvents[globalIndex + 1];
|
|
75
|
+
const hasNextSameTask = nextEvent && nextEvent.taskId === event.taskId;
|
|
76
|
+
return (_jsx(TimelineEntry, { event: event, isLast: isLast, hasNextSameTask: hasNextSameTask }, event.id));
|
|
77
|
+
}) }), canScrollDown && (_jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: theme.colors.keyboardHint, dimColor: true, children: "-- more below --" }) }))] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.keyboardHint, dimColor: true, children: "j/k: scroll" }) })] }) }));
|
|
78
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { TaskTree } from "../types/task";
|
|
3
|
+
import type { TimelineEvent } from "../types/timeline";
|
|
4
|
+
import type { CalendarDate } from "../types/calendar";
|
|
5
|
+
interface AppContextType {
|
|
6
|
+
selectedDate: CalendarDate;
|
|
7
|
+
setSelectedDate: (date: CalendarDate) => void;
|
|
8
|
+
tasks: TaskTree;
|
|
9
|
+
setTasks: (tasks: TaskTree) => void;
|
|
10
|
+
timeline: {
|
|
11
|
+
[date: string]: TimelineEvent[];
|
|
12
|
+
};
|
|
13
|
+
setTimeline: (timeline: {
|
|
14
|
+
[date: string]: TimelineEvent[];
|
|
15
|
+
}) => void;
|
|
16
|
+
activePane: "calendar" | "tasks" | "timeline";
|
|
17
|
+
setActivePane: (pane: "calendar" | "tasks" | "timeline") => void;
|
|
18
|
+
showHelp: boolean;
|
|
19
|
+
setShowHelp: (show: boolean) => void;
|
|
20
|
+
isInputMode: boolean;
|
|
21
|
+
setIsInputMode: (mode: boolean) => void;
|
|
22
|
+
showOverview: boolean;
|
|
23
|
+
setShowOverview: (show: boolean) => void;
|
|
24
|
+
overviewMonth: {
|
|
25
|
+
year: number;
|
|
26
|
+
month: number;
|
|
27
|
+
};
|
|
28
|
+
setOverviewMonth: (date: {
|
|
29
|
+
year: number;
|
|
30
|
+
month: number;
|
|
31
|
+
}) => void;
|
|
32
|
+
exitConfirmation: boolean;
|
|
33
|
+
setExitConfirmation: (show: boolean) => void;
|
|
34
|
+
showThemeDialog: boolean;
|
|
35
|
+
setShowThemeDialog: (show: boolean) => void;
|
|
36
|
+
showClearTimelineDialog: boolean;
|
|
37
|
+
setShowClearTimelineDialog: (show: boolean) => void;
|
|
38
|
+
clearTimelineForDate: (dateStr: string) => void;
|
|
39
|
+
isModalOpen: boolean;
|
|
40
|
+
saveNow: () => Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
interface AppProviderProps {
|
|
43
|
+
children: React.ReactNode;
|
|
44
|
+
}
|
|
45
|
+
export declare const AppProvider: React.FC<AppProviderProps>;
|
|
46
|
+
export declare const useApp: () => AppContextType;
|
|
47
|
+
export {};
|