@jlmarcos99/todos 0.0.0-stage → 1.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/README.md +139 -2
- package/dist/app/src/app.js +39 -0
- package/dist/app/src/application/operation-queue.js +9 -0
- package/dist/app/src/application/task-service.js +172 -0
- package/dist/app/src/cli/format.js +6 -0
- package/dist/app/src/cli/prompts.js +15 -0
- package/dist/app/src/cli.js +174 -0
- package/dist/app/src/config/config.js +25 -0
- package/dist/app/src/config/paths.js +95 -0
- package/dist/app/src/model/activity.js +1 -0
- package/dist/app/src/model/daily.js +34 -0
- package/dist/app/src/model/task.js +1 -0
- package/dist/app/src/model/vault.js +1 -0
- package/dist/app/src/storage/atomic-file.js +15 -0
- package/dist/app/src/storage/record-codec.js +57 -0
- package/dist/app/src/storage/store.js +187 -0
- package/dist/app/src/sync/git-client.js +50 -0
- package/dist/app/src/sync/git-repository.js +291 -0
- package/dist/app/src/sync/sync-service.js +97 -0
- package/dist/app/src/tui/demo.js +93 -0
- package/dist/app/src/tui/screen.js +325 -0
- package/dist/app/src/tui/session.js +167 -0
- package/dist/app/src/tui/theme.js +17 -0
- package/dist/app/src/tui/view.js +279 -0
- package/dist/index.js +3 -0
- package/package.json +55 -4
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export class SyncService {
|
|
2
|
+
repository;
|
|
3
|
+
queue;
|
|
4
|
+
snapshot = { state: 'local-only', message: 'Local only' };
|
|
5
|
+
listeners = new Set();
|
|
6
|
+
constructor(repository, queue) {
|
|
7
|
+
this.repository = repository;
|
|
8
|
+
this.queue = queue;
|
|
9
|
+
}
|
|
10
|
+
get status() { return this.snapshot; }
|
|
11
|
+
subscribe(listener) {
|
|
12
|
+
this.listeners.add(listener);
|
|
13
|
+
listener(this.snapshot);
|
|
14
|
+
return () => this.listeners.delete(listener);
|
|
15
|
+
}
|
|
16
|
+
async linkRemote(url) {
|
|
17
|
+
this.set('syncing', 'Linking Git remote…');
|
|
18
|
+
try {
|
|
19
|
+
await this.repository.linkRemote(url);
|
|
20
|
+
this.set('synced', 'Remote linked and synchronized');
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
this.set('error', `Could not link remote: ${safeMessage(error)}`);
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async syncBeforeRead() {
|
|
28
|
+
await this.refresh(async () => undefined);
|
|
29
|
+
}
|
|
30
|
+
async refresh(load) {
|
|
31
|
+
const hasRemote = await this.repository.hasRemote();
|
|
32
|
+
if (hasRemote)
|
|
33
|
+
this.set('syncing', 'Checking remote changes…');
|
|
34
|
+
else
|
|
35
|
+
this.set('local-only', 'Local only');
|
|
36
|
+
return this.queue.run(async () => {
|
|
37
|
+
if (hasRemote) {
|
|
38
|
+
try {
|
|
39
|
+
await this.repository.syncBeforeRead();
|
|
40
|
+
this.set('synced', 'Synced');
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
this.set('pending', `Could not pull remote changes: ${safeMessage(error)}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return load();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async retry() { await this.syncBeforeRead(); }
|
|
50
|
+
async commitAndPush(reason) {
|
|
51
|
+
if (!(await this.repository.hasRemote())) {
|
|
52
|
+
this.set('local-only', 'Saved locally');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
this.set('syncing', 'Saving and syncing…');
|
|
56
|
+
try {
|
|
57
|
+
await this.queue.run(() => this.repository.commitAndPush(reason));
|
|
58
|
+
this.set('synced', 'Synced');
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
this.set('pending', `Saved locally; sync pending: ${safeMessage(error)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async mutateAndSync(reason, mutation, afterSync) {
|
|
65
|
+
return this.queue.run(async () => {
|
|
66
|
+
const result = await mutation();
|
|
67
|
+
if (!(await this.repository.hasRemote())) {
|
|
68
|
+
this.set('local-only', 'Saved locally');
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
this.set('syncing', 'Saving and syncing…');
|
|
72
|
+
try {
|
|
73
|
+
await this.repository.commitAndPush(reason);
|
|
74
|
+
this.set('synced', 'Synced');
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
this.set('pending', `Saved locally; sync pending: ${safeMessage(error)}`);
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
await afterSync?.();
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
this.set('pending', `Saved locally; refresh failed: ${safeMessage(error)}`);
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
async unlink() { await this.repository.unlink(); this.set('local-only', 'Remote unlinked; local history kept'); }
|
|
89
|
+
set(state, message) {
|
|
90
|
+
this.snapshot = { state, message };
|
|
91
|
+
for (const listener of this.listeners)
|
|
92
|
+
listener(this.snapshot);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function safeMessage(error) {
|
|
96
|
+
return error instanceof Error ? error.message : String(error);
|
|
97
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const HOUR = 60 * 60 * 1_000;
|
|
2
|
+
const DAY = 24 * HOUR;
|
|
3
|
+
/** Fresh data for every session. No files or repositories are modified. */
|
|
4
|
+
export function createDemoVaults(now) {
|
|
5
|
+
function task(id, name, description, completedHoursAgo) {
|
|
6
|
+
const result = {
|
|
7
|
+
id,
|
|
8
|
+
name,
|
|
9
|
+
description,
|
|
10
|
+
date: new Date(now.getTime() - 3 * DAY),
|
|
11
|
+
status: completedHoursAgo !== undefined ? 'done' : 'todo',
|
|
12
|
+
};
|
|
13
|
+
if (completedHoursAgo !== undefined) {
|
|
14
|
+
result.completedAt = new Date(now.getTime() - completedHoursAgo * HOUR);
|
|
15
|
+
}
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
const vaults = [
|
|
19
|
+
{
|
|
20
|
+
id: 'work', name: 'Work', description: 'A clear picture of the work that matters.',
|
|
21
|
+
activity: [],
|
|
22
|
+
tasks: [
|
|
23
|
+
task('w1', 'Build the activity view', 'Keep daily progress visible, even before a task is finished.'),
|
|
24
|
+
task('w2', 'Review the authentication flow', 'Check token refresh and session expiry handling.'),
|
|
25
|
+
task('w3', 'Document the API changes', 'Write migration notes and a short usage example.'),
|
|
26
|
+
task('w4', 'Investigate the flaky build', 'Reproduce the failure and isolate the slow test.'),
|
|
27
|
+
task('w5', 'Review the pull request', 'Feedback sent and changes approved.', 2),
|
|
28
|
+
task('w6', 'Fix the pagination bug', 'The final page now returns the expected items.', 30),
|
|
29
|
+
task('w7', 'Add request validation', 'Invalid requests now return clear errors.', 72),
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'portfolio', name: 'Portfolio', description: 'Small ideas becoming finished projects.',
|
|
34
|
+
activity: [],
|
|
35
|
+
tasks: [
|
|
36
|
+
task('p1', 'Design the Todos TUI', 'Try Vim navigation, vault switching and task states.'),
|
|
37
|
+
task('p2', 'Write the README', 'Explain the project with a small, practical example.'),
|
|
38
|
+
task('p3', 'Record a terminal demo', 'Show a short workflow across multiple vaults.'),
|
|
39
|
+
task('p4', 'Check accessibility', 'Try a narrow terminal and disable color.'),
|
|
40
|
+
task('p5', 'Define the task model', 'An initial model ready for the storage layer.', 5),
|
|
41
|
+
task('p6', 'Set up the NPM project', 'The initial project configuration is ready.', 48),
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'personal', name: 'Personal', description: 'A little room for everything outside work.',
|
|
46
|
+
activity: [],
|
|
47
|
+
tasks: [
|
|
48
|
+
task('i1', 'Plan the week', 'Choose three priorities and make room for them.'),
|
|
49
|
+
task('i2', 'Read a chapter', 'Put the phone aside for twenty minutes.'),
|
|
50
|
+
task('i3', 'Go for a walk', 'A short break outside before finishing the day.', 50),
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
for (const vault of vaults) {
|
|
55
|
+
for (const task of vault.tasks) {
|
|
56
|
+
if (!task.completedAt)
|
|
57
|
+
continue;
|
|
58
|
+
vault.activity.push({
|
|
59
|
+
id: `demo-completed-${task.id}`,
|
|
60
|
+
taskId: task.id,
|
|
61
|
+
taskName: task.name,
|
|
62
|
+
date: task.completedAt,
|
|
63
|
+
kind: 'completed',
|
|
64
|
+
status: 'done',
|
|
65
|
+
note: task.description,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const ongoing = vault.tasks[1];
|
|
69
|
+
ongoing.status = 'in-progress';
|
|
70
|
+
const yesterday = new Date(now);
|
|
71
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
72
|
+
yesterday.setHours(14, 30, 0, 0);
|
|
73
|
+
const today = new Date(now);
|
|
74
|
+
vault.activity.push({
|
|
75
|
+
id: `demo-yesterday-${ongoing.id}`,
|
|
76
|
+
taskId: ongoing.id,
|
|
77
|
+
taskName: ongoing.name,
|
|
78
|
+
date: yesterday,
|
|
79
|
+
kind: 'note',
|
|
80
|
+
status: 'in-progress',
|
|
81
|
+
note: 'Finished the first pass and identified a few edge cases.',
|
|
82
|
+
}, {
|
|
83
|
+
id: `demo-today-${ongoing.id}`,
|
|
84
|
+
taskId: ongoing.id,
|
|
85
|
+
taskName: ongoing.name,
|
|
86
|
+
date: today,
|
|
87
|
+
kind: 'note',
|
|
88
|
+
status: 'in-progress',
|
|
89
|
+
note: 'Working through the remaining edge cases. Next: final review.',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return vaults;
|
|
93
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { Input, matchesKey, parseKey } from '@earendil-works/pi-tui';
|
|
2
|
+
import { createDemoVaults } from "./demo.js";
|
|
3
|
+
import { TodoSession } from "./session.js";
|
|
4
|
+
import { theme } from "./theme.js";
|
|
5
|
+
import { activityViewportHeight, dailyListLayout, dailyViewportHeight, mainColumnWidth, renderScreen, taskActivityLines } from "./view.js";
|
|
6
|
+
const DOUBLE_G_TIMEOUT = 600;
|
|
7
|
+
/** Connects terminal input and rendering to the application session. */
|
|
8
|
+
export class TodoScreen {
|
|
9
|
+
session;
|
|
10
|
+
options;
|
|
11
|
+
input = new Input();
|
|
12
|
+
mode = 'normal';
|
|
13
|
+
showHelp = false;
|
|
14
|
+
showActivity = false;
|
|
15
|
+
activityOffset = 0;
|
|
16
|
+
dailyOffset = 0;
|
|
17
|
+
followDailySelection = true;
|
|
18
|
+
activityTaskId;
|
|
19
|
+
noteTaskId;
|
|
20
|
+
viewportWidth = 80;
|
|
21
|
+
previousGTime = 0;
|
|
22
|
+
pendingDeleteId;
|
|
23
|
+
unsubscribeSync;
|
|
24
|
+
constructor(options) {
|
|
25
|
+
this.options = options;
|
|
26
|
+
this.session = new TodoSession(options.vaults ?? createDemoVaults(options.now()), options.now, options.tasks);
|
|
27
|
+
options.tasks?.setOnReload(vaults => {
|
|
28
|
+
this.session.replaceVaults(vaults);
|
|
29
|
+
options.redraw();
|
|
30
|
+
});
|
|
31
|
+
this.unsubscribeSync = options.sync?.subscribe(snapshot => {
|
|
32
|
+
this.session.syncMessage = snapshot.message;
|
|
33
|
+
options.redraw();
|
|
34
|
+
});
|
|
35
|
+
this.input.onSubmit = value => this.submitInput(value);
|
|
36
|
+
this.input.onEscape = () => this.closeInput();
|
|
37
|
+
}
|
|
38
|
+
dispose() { this.unsubscribeSync?.(); }
|
|
39
|
+
invalidate() { }
|
|
40
|
+
handleInput(data) {
|
|
41
|
+
const command = matchesKey(data, 'shift+g') ? 'G' : matchesKey(data, 'shift+v') ? 'V' : parseKey(data) ?? data;
|
|
42
|
+
if (command !== 'g')
|
|
43
|
+
this.previousGTime = 0;
|
|
44
|
+
if (matchesKey(data, 'ctrl+c')) {
|
|
45
|
+
this.options.quit();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (this.mode !== 'normal') {
|
|
49
|
+
this.input.handleInput(data);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
this.handleCommand(command);
|
|
53
|
+
}
|
|
54
|
+
this.session.clampSelection();
|
|
55
|
+
this.options.redraw();
|
|
56
|
+
}
|
|
57
|
+
handleCommand(command) {
|
|
58
|
+
const confirmDelete = this.pendingDeleteId !== undefined && command === 'd'
|
|
59
|
+
&& this.session.selectedTask?.id === this.pendingDeleteId;
|
|
60
|
+
if (this.pendingDeleteId !== undefined && !confirmDelete)
|
|
61
|
+
this.session.message = 'Delete cancelled.';
|
|
62
|
+
this.pendingDeleteId = undefined;
|
|
63
|
+
if (command !== 'ctrl+d' && command !== 'ctrl+u')
|
|
64
|
+
this.followDailySelection = true;
|
|
65
|
+
if (command === 'q') {
|
|
66
|
+
this.options.quit();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (command === '?' || command === 'shift+/') {
|
|
70
|
+
this.showHelp = !this.showHelp;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (this.showHelp) {
|
|
74
|
+
if (command === 'escape')
|
|
75
|
+
this.showHelp = false;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (this.showActivity) {
|
|
79
|
+
this.handleActivityCommand(command);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (confirmDelete) {
|
|
83
|
+
this.session.deleteSelectedTask();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
switch (command) {
|
|
87
|
+
case 'tab':
|
|
88
|
+
this.session.switchVault(1);
|
|
89
|
+
this.refreshVaults();
|
|
90
|
+
break;
|
|
91
|
+
case 'shift+tab':
|
|
92
|
+
this.session.switchVault(-1);
|
|
93
|
+
this.refreshVaults();
|
|
94
|
+
break;
|
|
95
|
+
case 'j':
|
|
96
|
+
case 'down':
|
|
97
|
+
this.session.moveSelection(1);
|
|
98
|
+
break;
|
|
99
|
+
case 'k':
|
|
100
|
+
case 'up':
|
|
101
|
+
this.session.moveSelection(-1);
|
|
102
|
+
break;
|
|
103
|
+
case 'G':
|
|
104
|
+
this.session.selected = this.session.tasks.length - 1;
|
|
105
|
+
break;
|
|
106
|
+
case 'g':
|
|
107
|
+
this.handleDoubleG();
|
|
108
|
+
break;
|
|
109
|
+
case 'h':
|
|
110
|
+
case 'left':
|
|
111
|
+
this.session.cycleView(-1);
|
|
112
|
+
break;
|
|
113
|
+
case 'l':
|
|
114
|
+
case 'right':
|
|
115
|
+
this.session.cycleView(1);
|
|
116
|
+
break;
|
|
117
|
+
case '1':
|
|
118
|
+
this.session.switchView('current');
|
|
119
|
+
break;
|
|
120
|
+
case '2':
|
|
121
|
+
this.session.switchView('daily');
|
|
122
|
+
break;
|
|
123
|
+
case '3':
|
|
124
|
+
this.session.switchView('history');
|
|
125
|
+
break;
|
|
126
|
+
case 'space':
|
|
127
|
+
this.session.toggleSelectedTask();
|
|
128
|
+
break;
|
|
129
|
+
case 's':
|
|
130
|
+
this.session.startSelectedTask();
|
|
131
|
+
break;
|
|
132
|
+
case 'n':
|
|
133
|
+
if (this.session.selectedTask)
|
|
134
|
+
this.openInput('note');
|
|
135
|
+
break;
|
|
136
|
+
case '[':
|
|
137
|
+
this.session.changeDay(-1);
|
|
138
|
+
break;
|
|
139
|
+
case ']':
|
|
140
|
+
this.session.changeDay(1);
|
|
141
|
+
break;
|
|
142
|
+
case 't':
|
|
143
|
+
this.session.showToday();
|
|
144
|
+
break;
|
|
145
|
+
case 'ctrl+d':
|
|
146
|
+
this.scrollDailyNotes(1);
|
|
147
|
+
break;
|
|
148
|
+
case 'ctrl+u':
|
|
149
|
+
this.scrollDailyNotes(-1);
|
|
150
|
+
break;
|
|
151
|
+
case 'enter':
|
|
152
|
+
if (this.session.selectedTask) {
|
|
153
|
+
this.showActivity = true;
|
|
154
|
+
this.activityTaskId = this.session.selectedTask.id;
|
|
155
|
+
this.activityOffset = 0;
|
|
156
|
+
}
|
|
157
|
+
break;
|
|
158
|
+
case 'd': {
|
|
159
|
+
const task = this.session.selectedTask;
|
|
160
|
+
if (!task)
|
|
161
|
+
break;
|
|
162
|
+
this.pendingDeleteId = task.id;
|
|
163
|
+
this.session.message = `Delete "${task.name}"? Press d again to confirm.`;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case 'a':
|
|
167
|
+
this.openInput('add');
|
|
168
|
+
break;
|
|
169
|
+
case 'V':
|
|
170
|
+
this.openInput('vault');
|
|
171
|
+
break;
|
|
172
|
+
case 'r':
|
|
173
|
+
if (this.options.sync?.status.state === 'local-only')
|
|
174
|
+
this.openInput('link');
|
|
175
|
+
else if (this.options.refresh)
|
|
176
|
+
this.refreshVaults();
|
|
177
|
+
else
|
|
178
|
+
void this.options.sync?.retry();
|
|
179
|
+
break;
|
|
180
|
+
case '/':
|
|
181
|
+
this.openInput('search');
|
|
182
|
+
break;
|
|
183
|
+
case 'escape':
|
|
184
|
+
this.session.search('');
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
handleDoubleG(goToFirst = () => { this.session.selected = 0; }) {
|
|
189
|
+
const time = Date.now();
|
|
190
|
+
if (time - this.previousGTime < DOUBLE_G_TIMEOUT) {
|
|
191
|
+
goToFirst();
|
|
192
|
+
this.previousGTime = 0;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
this.previousGTime = time;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
handleActivityCommand(command) {
|
|
199
|
+
const maxOffset = this.maxActivityOffset;
|
|
200
|
+
this.activityOffset = Math.min(this.activityOffset, maxOffset);
|
|
201
|
+
switch (command) {
|
|
202
|
+
case 'escape':
|
|
203
|
+
case 'enter':
|
|
204
|
+
this.showActivity = false;
|
|
205
|
+
break;
|
|
206
|
+
case 'j':
|
|
207
|
+
case 'down':
|
|
208
|
+
this.activityOffset = Math.min(maxOffset, this.activityOffset + 1);
|
|
209
|
+
break;
|
|
210
|
+
case 'k':
|
|
211
|
+
case 'up':
|
|
212
|
+
this.activityOffset = Math.max(0, this.activityOffset - 1);
|
|
213
|
+
break;
|
|
214
|
+
case 'G':
|
|
215
|
+
this.activityOffset = maxOffset;
|
|
216
|
+
break;
|
|
217
|
+
case 'g':
|
|
218
|
+
this.handleDoubleG(() => { this.activityOffset = 0; });
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
get maxActivityOffset() {
|
|
223
|
+
const lineCount = taskActivityLines(this.session, mainColumnWidth(this.viewportWidth), this.activityTaskId).length;
|
|
224
|
+
const visibleRows = activityViewportHeight(this.options.rows());
|
|
225
|
+
return Math.max(0, lineCount - visibleRows);
|
|
226
|
+
}
|
|
227
|
+
scrollDailyNotes(direction) {
|
|
228
|
+
if (this.session.view !== 'daily')
|
|
229
|
+
return;
|
|
230
|
+
const { lines } = dailyListLayout(this.session, mainColumnWidth(this.viewportWidth));
|
|
231
|
+
const viewportHeight = dailyViewportHeight(this.options.rows());
|
|
232
|
+
const maxOffset = Math.max(0, lines.length - viewportHeight);
|
|
233
|
+
const step = Math.max(1, Math.floor(viewportHeight / 2));
|
|
234
|
+
this.dailyOffset = Math.max(0, Math.min(maxOffset, this.dailyOffset + direction * step));
|
|
235
|
+
this.followDailySelection = false;
|
|
236
|
+
}
|
|
237
|
+
updateDailyViewport() {
|
|
238
|
+
const { lines, taskRows } = dailyListLayout(this.session, mainColumnWidth(this.viewportWidth));
|
|
239
|
+
const viewportHeight = dailyViewportHeight(this.options.rows());
|
|
240
|
+
if (this.followDailySelection) {
|
|
241
|
+
const taskStart = taskRows[this.session.selected] ?? 0;
|
|
242
|
+
const taskEnd = taskRows[this.session.selected + 1] ?? lines.length;
|
|
243
|
+
const visibleTaskEnd = taskStart + Math.min(viewportHeight, taskEnd - taskStart);
|
|
244
|
+
if (taskStart < this.dailyOffset)
|
|
245
|
+
this.dailyOffset = taskStart;
|
|
246
|
+
if (visibleTaskEnd > this.dailyOffset + viewportHeight) {
|
|
247
|
+
this.dailyOffset = visibleTaskEnd - viewportHeight;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const maxOffset = Math.max(0, lines.length - viewportHeight);
|
|
251
|
+
this.dailyOffset = Math.max(0, Math.min(maxOffset, this.dailyOffset));
|
|
252
|
+
}
|
|
253
|
+
openInput(mode) {
|
|
254
|
+
this.mode = mode;
|
|
255
|
+
this.noteTaskId = mode === 'note' ? this.session.selectedTask?.id : undefined;
|
|
256
|
+
this.input.setValue(mode === 'search' ? this.session.query : '');
|
|
257
|
+
}
|
|
258
|
+
submitInput(value) {
|
|
259
|
+
if (this.mode === 'add')
|
|
260
|
+
this.session.addTask(value);
|
|
261
|
+
if (this.mode === 'search')
|
|
262
|
+
this.session.search(value);
|
|
263
|
+
if (this.mode === 'note')
|
|
264
|
+
this.session.addProgressNote(value, this.noteTaskId);
|
|
265
|
+
if (this.mode === 'vault')
|
|
266
|
+
this.session.createVault(value);
|
|
267
|
+
if (this.mode === 'link')
|
|
268
|
+
this.linkRemote(value);
|
|
269
|
+
this.closeInput();
|
|
270
|
+
}
|
|
271
|
+
linkRemote(url) {
|
|
272
|
+
if (!url.trim()) {
|
|
273
|
+
this.session.syncMessage = 'Enter a Git remote URL to link this vault.';
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const sync = this.options.sync;
|
|
277
|
+
if (!sync)
|
|
278
|
+
return;
|
|
279
|
+
void sync.linkRemote(url).then(() => this.refreshVaults()).catch(error => {
|
|
280
|
+
this.session.syncMessage = `Could not link remote: ${error.message}`;
|
|
281
|
+
this.options.redraw();
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
refreshVaults() {
|
|
285
|
+
if (!this.options.refresh)
|
|
286
|
+
return;
|
|
287
|
+
void this.options.refresh().then(vaults => this.session.replaceVaults(vaults)).catch(error => {
|
|
288
|
+
this.session.syncMessage = `Refresh failed: ${error.message}`;
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
closeInput() {
|
|
292
|
+
this.mode = 'normal';
|
|
293
|
+
this.noteTaskId = undefined;
|
|
294
|
+
this.options.redraw();
|
|
295
|
+
}
|
|
296
|
+
render(width) {
|
|
297
|
+
this.viewportWidth = width;
|
|
298
|
+
if (this.showActivity) {
|
|
299
|
+
this.activityOffset = Math.min(this.activityOffset, this.maxActivityOffset);
|
|
300
|
+
}
|
|
301
|
+
this.session.clampSelection();
|
|
302
|
+
if (this.session.view === 'daily')
|
|
303
|
+
this.updateDailyViewport();
|
|
304
|
+
let inputLine;
|
|
305
|
+
if (this.mode !== 'normal') {
|
|
306
|
+
const labels = { add: '+ New task ', search: '/ Search ', note: '+ Progress note ' };
|
|
307
|
+
const label = this.mode === 'vault' ? '+ New vault '
|
|
308
|
+
: this.mode === 'link' ? '+ Git remote URL '
|
|
309
|
+
: labels[this.mode];
|
|
310
|
+
// Leave room for the sidebar and the label on larger terminals.
|
|
311
|
+
const inputWidth = Math.max(1, mainColumnWidth(width) - label.length - 3);
|
|
312
|
+
inputLine = ` ${theme.accent(label)}${this.input.render(inputWidth)[0] ?? ''}`;
|
|
313
|
+
}
|
|
314
|
+
return renderScreen(this.session, {
|
|
315
|
+
width,
|
|
316
|
+
rows: this.options.rows(),
|
|
317
|
+
showHelp: this.showHelp,
|
|
318
|
+
showActivity: this.showActivity,
|
|
319
|
+
activityTaskId: this.activityTaskId,
|
|
320
|
+
activityOffset: this.activityOffset,
|
|
321
|
+
dailyOffset: this.dailyOffset,
|
|
322
|
+
inputLine,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { getDailyItems } from "../model/daily.js";
|
|
2
|
+
import { TaskService } from "../application/task-service.js";
|
|
3
|
+
const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1_000;
|
|
4
|
+
export const TASK_VIEWS = ['current', 'daily', 'history'];
|
|
5
|
+
/** In-memory application state. It does not depend on terminal rendering. */
|
|
6
|
+
export class TodoSession {
|
|
7
|
+
vaults;
|
|
8
|
+
now;
|
|
9
|
+
tasksService;
|
|
10
|
+
vaultIndex = 0;
|
|
11
|
+
view = 'current';
|
|
12
|
+
selected = 0;
|
|
13
|
+
query = '';
|
|
14
|
+
dailyDate;
|
|
15
|
+
message = 'Track what is next, what is moving, and what you have done.';
|
|
16
|
+
syncMessage = 'Local only';
|
|
17
|
+
constructor(vaults, now, tasksService = new TaskService()) {
|
|
18
|
+
this.vaults = vaults;
|
|
19
|
+
this.now = now;
|
|
20
|
+
this.tasksService = tasksService;
|
|
21
|
+
this.tasksService.setVaults(vaults);
|
|
22
|
+
this.dailyDate = new Date(now());
|
|
23
|
+
}
|
|
24
|
+
get vault() {
|
|
25
|
+
return this.vaults[this.vaultIndex];
|
|
26
|
+
}
|
|
27
|
+
get dailyItems() {
|
|
28
|
+
return getDailyItems(this.vault, this.dailyDate)
|
|
29
|
+
.filter(item => this.matchesSearch(item.name));
|
|
30
|
+
}
|
|
31
|
+
get tasks() {
|
|
32
|
+
if (this.view === 'daily')
|
|
33
|
+
return this.dailyItems.map(item => item.task);
|
|
34
|
+
return this.vault.tasks.filter(task => {
|
|
35
|
+
const matchesView = this.isInHistory(task) === (this.view === 'history');
|
|
36
|
+
return matchesView && this.matchesSearch(task.name);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
get selectedTask() {
|
|
40
|
+
return this.tasks[this.selected];
|
|
41
|
+
}
|
|
42
|
+
matchesSearch(name) {
|
|
43
|
+
return name.toLocaleLowerCase().includes(this.query.toLocaleLowerCase());
|
|
44
|
+
}
|
|
45
|
+
isInHistory(task) {
|
|
46
|
+
if (task.status !== 'done' || !task.completedAt)
|
|
47
|
+
return false;
|
|
48
|
+
const elapsed = this.now().getTime() - task.completedAt.getTime();
|
|
49
|
+
return elapsed > DAY_IN_MILLISECONDS;
|
|
50
|
+
}
|
|
51
|
+
moveSelection(offset) {
|
|
52
|
+
this.selected += offset;
|
|
53
|
+
this.clampSelection();
|
|
54
|
+
}
|
|
55
|
+
clampSelection() {
|
|
56
|
+
const lastIndex = Math.max(0, this.tasks.length - 1);
|
|
57
|
+
this.selected = Math.max(0, Math.min(this.selected, lastIndex));
|
|
58
|
+
}
|
|
59
|
+
switchVault(direction) {
|
|
60
|
+
this.vaultIndex = (this.vaultIndex + direction + this.vaults.length) % this.vaults.length;
|
|
61
|
+
this.selected = 0;
|
|
62
|
+
this.query = '';
|
|
63
|
+
this.message = this.vault.description;
|
|
64
|
+
}
|
|
65
|
+
switchView(view) {
|
|
66
|
+
this.view = view;
|
|
67
|
+
this.selected = 0;
|
|
68
|
+
}
|
|
69
|
+
cycleView(direction) {
|
|
70
|
+
const index = TASK_VIEWS.indexOf(this.view);
|
|
71
|
+
const nextIndex = (index + direction + TASK_VIEWS.length) % TASK_VIEWS.length;
|
|
72
|
+
this.switchView(TASK_VIEWS[nextIndex]);
|
|
73
|
+
}
|
|
74
|
+
startSelectedTask() {
|
|
75
|
+
const task = this.selectedTask;
|
|
76
|
+
if (!task || task.status === 'in-progress')
|
|
77
|
+
return;
|
|
78
|
+
this.message = 'In progress. Press n to add a progress note.';
|
|
79
|
+
void this.runMutation(() => this.tasksService.startTask(this.vault, task, this.now()));
|
|
80
|
+
this.clampSelection();
|
|
81
|
+
}
|
|
82
|
+
deleteSelectedTask() {
|
|
83
|
+
const task = this.selectedTask;
|
|
84
|
+
if (!task)
|
|
85
|
+
return;
|
|
86
|
+
const name = task.name;
|
|
87
|
+
void this.runMutation(() => this.tasksService.deleteTask(this.vault, task));
|
|
88
|
+
this.message = `Deleted "${name}".`;
|
|
89
|
+
this.clampSelection();
|
|
90
|
+
}
|
|
91
|
+
toggleSelectedTask() {
|
|
92
|
+
const task = this.selectedTask;
|
|
93
|
+
if (!task)
|
|
94
|
+
return;
|
|
95
|
+
const wasDone = task.status === 'done';
|
|
96
|
+
this.message = wasDone ? 'Task reopened. Previous daily entries are preserved.' : 'Done. Recorded in today’s Daily.';
|
|
97
|
+
void this.runMutation(() => this.tasksService.toggleTask(this.vault, task, this.now()));
|
|
98
|
+
this.clampSelection();
|
|
99
|
+
}
|
|
100
|
+
addProgressNote(note, taskId = this.selectedTask?.id) {
|
|
101
|
+
const task = this.vault.tasks.find(task => task.id === taskId);
|
|
102
|
+
if (!task || !note.trim())
|
|
103
|
+
return;
|
|
104
|
+
this.message = 'Progress saved in today’s Daily.';
|
|
105
|
+
void this.runMutation(() => this.tasksService.addProgressNote(this.vault, task, note, this.now()));
|
|
106
|
+
this.clampSelection();
|
|
107
|
+
}
|
|
108
|
+
addTask(name) {
|
|
109
|
+
if (!name.trim())
|
|
110
|
+
return;
|
|
111
|
+
this.view = 'current';
|
|
112
|
+
this.query = '';
|
|
113
|
+
const operation = this.tasksService.addTask(this.vault, name, this.now());
|
|
114
|
+
this.selected = Math.max(0, this.tasks.length - 1);
|
|
115
|
+
void operation.then(task => {
|
|
116
|
+
if (task) {
|
|
117
|
+
this.selected = this.tasks.findIndex(item => item.id === task.id);
|
|
118
|
+
this.message = 'Task added.';
|
|
119
|
+
}
|
|
120
|
+
}).catch(error => { this.message = `Could not save task: ${error.message}`; });
|
|
121
|
+
}
|
|
122
|
+
search(query) {
|
|
123
|
+
this.query = query.trim();
|
|
124
|
+
this.selected = 0;
|
|
125
|
+
}
|
|
126
|
+
changeDay(offset) {
|
|
127
|
+
const date = new Date(this.dailyDate);
|
|
128
|
+
date.setDate(date.getDate() + offset);
|
|
129
|
+
this.dailyDate = date;
|
|
130
|
+
this.switchView('daily');
|
|
131
|
+
}
|
|
132
|
+
showToday() {
|
|
133
|
+
this.dailyDate = new Date(this.now());
|
|
134
|
+
this.switchView('daily');
|
|
135
|
+
}
|
|
136
|
+
replaceVaults(vaults) {
|
|
137
|
+
const previousVaultId = this.vault?.id;
|
|
138
|
+
const previousTaskId = this.selectedTask?.id;
|
|
139
|
+
this.vaults = vaults;
|
|
140
|
+
this.tasksService.setVaults(vaults);
|
|
141
|
+
const nextVault = vaults.findIndex(vault => vault.id === previousVaultId);
|
|
142
|
+
this.vaultIndex = nextVault >= 0 ? nextVault : 0;
|
|
143
|
+
const nextTask = this.tasks.findIndex(task => task.id === previousTaskId);
|
|
144
|
+
this.selected = nextTask >= 0 ? nextTask : 0;
|
|
145
|
+
this.clampSelection();
|
|
146
|
+
}
|
|
147
|
+
createVault(name) {
|
|
148
|
+
if (!name.trim())
|
|
149
|
+
return;
|
|
150
|
+
void this.tasksService.createVault(name).then(vault => {
|
|
151
|
+
if (!this.vaults.some(existing => existing.id === vault.id))
|
|
152
|
+
this.vaults.push(vault);
|
|
153
|
+
this.tasksService.setVaults(this.vaults);
|
|
154
|
+
this.vaultIndex = this.vaults.findIndex(existing => existing.id === vault.id);
|
|
155
|
+
this.selected = 0;
|
|
156
|
+
this.message = `Vault ${vault.name} created.`;
|
|
157
|
+
}).catch(error => { this.message = `Could not create vault: ${error.message}`; });
|
|
158
|
+
}
|
|
159
|
+
async runMutation(operation) {
|
|
160
|
+
try {
|
|
161
|
+
await operation();
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
this.message = `Could not save change: ${error.message}`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|