@masslessai/push-todo 4.3.0 → 4.5.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/lib/api.js +9 -2
- package/lib/cli.js +74 -4
- package/lib/cron.js +18 -4
- package/lib/daemon.js +1 -87
- package/lib/reminder-parser.js +418 -0
- package/package.json +1 -1
package/lib/api.js
CHANGED
|
@@ -154,9 +154,12 @@ export async function markTaskCompleted(taskId, comment = '') {
|
|
|
154
154
|
* @param {string} options.title - Todo title (required)
|
|
155
155
|
* @param {string|null} options.content - Detailed content (optional)
|
|
156
156
|
* @param {boolean} options.backlog - Whether to create as backlog item
|
|
157
|
-
* @
|
|
157
|
+
* @param {string|null} options.reminderDate - ISO8601 reminder date (optional)
|
|
158
|
+
* @param {string|null} options.reminderTimeSource - Time source enum (optional)
|
|
159
|
+
* @param {boolean} options.alarmEnabled - Whether to mark as urgent (optional)
|
|
160
|
+
* @returns {Promise<Object>} Created todo with { id, displayNumber, title, createdAt, reminderDate, reminderEnabled }
|
|
158
161
|
*/
|
|
159
|
-
export async function createTodo({ title, content = null, backlog = false }) {
|
|
162
|
+
export async function createTodo({ title, content = null, backlog = false, reminderDate = null, reminderTimeSource = null, alarmEnabled = false }) {
|
|
160
163
|
const response = await apiRequest('create-todo', {
|
|
161
164
|
method: 'POST',
|
|
162
165
|
body: JSON.stringify({
|
|
@@ -164,6 +167,10 @@ export async function createTodo({ title, content = null, backlog = false }) {
|
|
|
164
167
|
normalizedContent: content || null,
|
|
165
168
|
isBacklog: backlog,
|
|
166
169
|
createdByClient: 'cli',
|
|
170
|
+
reminderDate: reminderDate || null,
|
|
171
|
+
reminderEnabled: reminderDate != null,
|
|
172
|
+
reminderTimeSource: reminderTimeSource || null,
|
|
173
|
+
alarmEnabled,
|
|
167
174
|
}),
|
|
168
175
|
});
|
|
169
176
|
|
package/lib/cli.js
CHANGED
|
@@ -19,6 +19,7 @@ import { install as installLaunchAgent, uninstall as uninstallLaunchAgent, getSt
|
|
|
19
19
|
import { getScreenshotPath, screenshotExists, openScreenshot } from './utils/screenshots.js';
|
|
20
20
|
import { bold, red, cyan, dim, green } from './utils/colors.js';
|
|
21
21
|
import { getMachineId } from './machine-id.js';
|
|
22
|
+
import { parseReminder } from './reminder-parser.js';
|
|
22
23
|
|
|
23
24
|
const __filename = fileURLToPath(import.meta.url);
|
|
24
25
|
const __dirname = dirname(__filename);
|
|
@@ -45,6 +46,9 @@ ${bold('USAGE:')}
|
|
|
45
46
|
push-todo [options] List active tasks
|
|
46
47
|
push-todo <number> Show specific task
|
|
47
48
|
push-todo create <title> Create a new todo
|
|
49
|
+
--remind <text> Set reminder ("tomorrow night", "in 2 hours")
|
|
50
|
+
--remind-at <iso> Set reminder at exact ISO8601 time
|
|
51
|
+
--alarm Mark as urgent (bypasses Focus)
|
|
48
52
|
push-todo connect Run connection doctor
|
|
49
53
|
push-todo search <query> Search tasks
|
|
50
54
|
push-todo review Review completed tasks
|
|
@@ -92,6 +96,8 @@ ${bold('EXAMPLES:')}
|
|
|
92
96
|
push-todo List active tasks for current project
|
|
93
97
|
push-todo 427 Show task #427
|
|
94
98
|
push-todo create "Fix auth bug" Create a new todo
|
|
99
|
+
push-todo create "Debug" --remind "tomorrow night"
|
|
100
|
+
push-todo create "Call" --remind "at 3pm" --alarm
|
|
95
101
|
push-todo create "Item" --backlog Create as backlog item
|
|
96
102
|
push-todo -a List all tasks across projects
|
|
97
103
|
push-todo --queue 1,2,3 Queue tasks 1, 2, 3 for daemon
|
|
@@ -126,6 +132,7 @@ ${bold('SCHEDULE (remote scheduled jobs):')}
|
|
|
126
132
|
--create-todo <title> Create a new todo each fire
|
|
127
133
|
--queue-todo <todoId> Re-queue an existing todo each fire
|
|
128
134
|
--git-remote <remote> Route created todos to a project
|
|
135
|
+
--agent-type <type> Agent: claude-code, openclaw, openai-codex
|
|
129
136
|
--content <body> Expanded content for created todos
|
|
130
137
|
push-todo schedule list List all schedules
|
|
131
138
|
push-todo schedule remove <id> Remove a schedule
|
|
@@ -178,6 +185,10 @@ const options = {
|
|
|
178
185
|
'store-e2ee-key': { type: 'string' },
|
|
179
186
|
'description': { type: 'string' },
|
|
180
187
|
'auto': { type: 'boolean' },
|
|
188
|
+
// Create command options
|
|
189
|
+
'remind': { type: 'string' },
|
|
190
|
+
'remind-at': { type: 'string' },
|
|
191
|
+
'alarm': { type: 'boolean' },
|
|
181
192
|
// Confirm command options
|
|
182
193
|
'type': { type: 'string' },
|
|
183
194
|
'title': { type: 'string' },
|
|
@@ -191,6 +202,7 @@ const options = {
|
|
|
191
202
|
'cron': { type: 'string' },
|
|
192
203
|
'create-todo': { type: 'string' },
|
|
193
204
|
'git-remote': { type: 'string' },
|
|
205
|
+
'agent-type': { type: 'string' },
|
|
194
206
|
'queue-todo': { type: 'string' },
|
|
195
207
|
// Skill CLI options (Phase 3)
|
|
196
208
|
'report-progress': { type: 'string' },
|
|
@@ -684,22 +696,74 @@ export async function run(argv) {
|
|
|
684
696
|
console.error('');
|
|
685
697
|
console.error('Usage:');
|
|
686
698
|
console.error(' push-todo create "Fix the auth bug"');
|
|
687
|
-
console.error(' push-todo create "
|
|
688
|
-
console.error(' push-todo create "
|
|
699
|
+
console.error(' push-todo create "Debug daemon" --remind "tomorrow night"');
|
|
700
|
+
console.error(' push-todo create "Call dentist" --remind "at 3pm" --alarm');
|
|
701
|
+
console.error(' push-todo create "Deploy" --remind-at "2026-03-03T14:00:00"');
|
|
689
702
|
process.exit(1);
|
|
690
703
|
}
|
|
691
704
|
|
|
705
|
+
// Conflict check
|
|
706
|
+
if (values.remind && values['remind-at']) {
|
|
707
|
+
console.error(red('Error: Use --remind OR --remind-at, not both.'));
|
|
708
|
+
process.exit(1);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Parse reminder
|
|
712
|
+
let reminderDate = null;
|
|
713
|
+
let reminderTimeSource = null;
|
|
714
|
+
const alarmEnabled = values.alarm || false;
|
|
715
|
+
|
|
716
|
+
if (values['remind-at']) {
|
|
717
|
+
const parsed = new Date(values['remind-at']);
|
|
718
|
+
if (isNaN(parsed.getTime())) {
|
|
719
|
+
console.error(red('Error: --remind-at must be a valid ISO8601 date.'));
|
|
720
|
+
console.error(dim(' Example: 2026-03-03T14:00:00'));
|
|
721
|
+
process.exit(1);
|
|
722
|
+
}
|
|
723
|
+
if (parsed <= new Date()) {
|
|
724
|
+
console.error(red('Error: --remind-at must be in the future.'));
|
|
725
|
+
process.exit(1);
|
|
726
|
+
}
|
|
727
|
+
reminderDate = parsed.toISOString();
|
|
728
|
+
reminderTimeSource = 'userSpecified';
|
|
729
|
+
} else if (values.remind) {
|
|
730
|
+
const result = parseReminder(values.remind);
|
|
731
|
+
if (!result.date) {
|
|
732
|
+
console.error(red(`Error: Could not parse reminder: "${values.remind}"`));
|
|
733
|
+
console.error('');
|
|
734
|
+
console.error('Examples:');
|
|
735
|
+
console.error(' --remind "tomorrow"');
|
|
736
|
+
console.error(' --remind "tonight"');
|
|
737
|
+
console.error(' --remind "in 2 hours"');
|
|
738
|
+
console.error(' --remind "next monday at 3pm"');
|
|
739
|
+
process.exit(1);
|
|
740
|
+
}
|
|
741
|
+
reminderDate = result.date.toISOString();
|
|
742
|
+
reminderTimeSource = result.timeSource;
|
|
743
|
+
}
|
|
744
|
+
|
|
692
745
|
try {
|
|
693
746
|
const todo = await api.createTodo({
|
|
694
747
|
title,
|
|
695
748
|
content: values.content || null,
|
|
696
749
|
backlog: values.backlog || false,
|
|
750
|
+
reminderDate,
|
|
751
|
+
reminderTimeSource,
|
|
752
|
+
alarmEnabled,
|
|
697
753
|
});
|
|
698
754
|
|
|
699
755
|
if (values.json) {
|
|
700
756
|
console.log(JSON.stringify(todo, null, 2));
|
|
701
757
|
} else {
|
|
702
|
-
|
|
758
|
+
let msg = green(`Created todo #${todo.displayNumber}: ${todo.title}`);
|
|
759
|
+
if (reminderDate) {
|
|
760
|
+
const d = new Date(reminderDate);
|
|
761
|
+
msg += `\n ${dim('Reminder:')} ${d.toLocaleString()}`;
|
|
762
|
+
}
|
|
763
|
+
if (alarmEnabled) {
|
|
764
|
+
msg += ` ${dim('(urgent)')}`;
|
|
765
|
+
}
|
|
766
|
+
console.log(msg);
|
|
703
767
|
}
|
|
704
768
|
} catch (error) {
|
|
705
769
|
console.error(red(`Failed to create todo: ${error.message}`));
|
|
@@ -763,6 +827,9 @@ export async function run(argv) {
|
|
|
763
827
|
process.exit(1);
|
|
764
828
|
}
|
|
765
829
|
|
|
830
|
+
// Resolve agent type: explicit flag > auto-detect from caller
|
|
831
|
+
const agentType = values['agent-type'] || null;
|
|
832
|
+
|
|
766
833
|
try {
|
|
767
834
|
const response = await api.apiRequest('manage-schedules', {
|
|
768
835
|
method: 'POST',
|
|
@@ -771,6 +838,7 @@ export async function run(argv) {
|
|
|
771
838
|
scheduleType,
|
|
772
839
|
scheduleValue,
|
|
773
840
|
actionType,
|
|
841
|
+
agentType,
|
|
774
842
|
actionTitle,
|
|
775
843
|
actionContent,
|
|
776
844
|
gitRemote,
|
|
@@ -822,7 +890,8 @@ export async function run(argv) {
|
|
|
822
890
|
const actionStr = s.action_type === 'create-todo'
|
|
823
891
|
? `create-todo: ${s.action_title || ''}`
|
|
824
892
|
: `queue-todo: ${s.todo_id || '?'}`;
|
|
825
|
-
|
|
893
|
+
const agentStr = s.agent_type && s.agent_type !== 'claude-code' ? ` (${s.agent_type})` : '';
|
|
894
|
+
console.log(` ${status} ${s.name} [${schedStr}] → ${actionStr}${agentStr}`);
|
|
826
895
|
console.log(dim(` ID: ${s.id.slice(0, 8)} | Next: ${s.next_run_at || 'N/A'} | Last: ${s.last_run_at || 'never'}`));
|
|
827
896
|
}
|
|
828
897
|
} catch (error) {
|
|
@@ -896,6 +965,7 @@ ${bold('Examples:')}
|
|
|
896
965
|
push-todo schedule add --name "Daily standup" --every 24h --create-todo "Write standup update"
|
|
897
966
|
push-todo schedule add --name "Weekly review" --cron "0 9 * * 1" --create-todo "Weekly code review" --git-remote github.com/user/repo
|
|
898
967
|
push-todo schedule add --name "Re-run task" --every 4h --queue-todo <todoId>
|
|
968
|
+
push-todo schedule add --name "Codex review" --every 12h --create-todo "Review PRs" --git-remote github.com/user/repo --agent-type openai-codex
|
|
899
969
|
`);
|
|
900
970
|
return;
|
|
901
971
|
}
|
package/lib/cron.js
CHANGED
|
@@ -196,16 +196,21 @@ export function computeNextRun(schedule, fromDate = new Date()) {
|
|
|
196
196
|
* @param {Function} [logFn] - Optional log function
|
|
197
197
|
* @param {Object} [context] - Injected dependencies from daemon
|
|
198
198
|
* @param {Function} [context.apiRequest] - API request function
|
|
199
|
+
* @param {string} [context.machineId] - This machine's ID for targeted schedule filtering
|
|
199
200
|
*/
|
|
200
201
|
export async function checkAndRunRemoteSchedules(logFn, context = {}) {
|
|
201
202
|
const log = logFn || (() => {});
|
|
202
203
|
|
|
203
204
|
if (!context.apiRequest) return;
|
|
204
205
|
|
|
205
|
-
// 1. Fetch due schedules
|
|
206
|
+
// 1. Fetch due schedules (filtered by machine if available)
|
|
206
207
|
let schedules;
|
|
207
208
|
try {
|
|
208
|
-
|
|
209
|
+
let endpoint = 'manage-schedules?due=true';
|
|
210
|
+
if (context.machineId) {
|
|
211
|
+
endpoint += `&machine_id=${encodeURIComponent(context.machineId)}`;
|
|
212
|
+
}
|
|
213
|
+
const response = await context.apiRequest(endpoint, {
|
|
209
214
|
method: 'GET',
|
|
210
215
|
});
|
|
211
216
|
if (!response.ok) {
|
|
@@ -226,6 +231,7 @@ export async function checkAndRunRemoteSchedules(logFn, context = {}) {
|
|
|
226
231
|
// 2. Fire each due schedule
|
|
227
232
|
for (const schedule of schedules) {
|
|
228
233
|
const expectedNextRunAt = schedule.next_run_at;
|
|
234
|
+
let actionSucceeded = false;
|
|
229
235
|
|
|
230
236
|
try {
|
|
231
237
|
if (schedule.action_type === 'create-todo') {
|
|
@@ -238,7 +244,8 @@ export async function checkAndRunRemoteSchedules(logFn, context = {}) {
|
|
|
238
244
|
};
|
|
239
245
|
if (schedule.git_remote) {
|
|
240
246
|
payload.gitRemote = schedule.git_remote;
|
|
241
|
-
|
|
247
|
+
// Use schedule's agent_type instead of hardcoding claude-code
|
|
248
|
+
payload.actionType = schedule.agent_type || 'claude-code';
|
|
242
249
|
}
|
|
243
250
|
|
|
244
251
|
const todoResponse = await context.apiRequest('create-todo', {
|
|
@@ -248,6 +255,7 @@ export async function checkAndRunRemoteSchedules(logFn, context = {}) {
|
|
|
248
255
|
if (todoResponse.ok) {
|
|
249
256
|
const todoData = await todoResponse.json();
|
|
250
257
|
log(`Schedule "${schedule.name}": created todo #${todoData.todo?.displayNumber || '?'}`);
|
|
258
|
+
actionSucceeded = true;
|
|
251
259
|
} else {
|
|
252
260
|
log(`Schedule "${schedule.name}": create-todo failed (HTTP ${todoResponse.status})`);
|
|
253
261
|
}
|
|
@@ -277,6 +285,7 @@ export async function checkAndRunRemoteSchedules(logFn, context = {}) {
|
|
|
277
285
|
});
|
|
278
286
|
if (queueResponse.ok) {
|
|
279
287
|
log(`Schedule "${schedule.name}": queued todo ${schedule.todo_id}`);
|
|
288
|
+
actionSucceeded = true;
|
|
280
289
|
} else {
|
|
281
290
|
log(`Schedule "${schedule.name}": queue-todo failed (HTTP ${queueResponse.status})`);
|
|
282
291
|
}
|
|
@@ -285,7 +294,12 @@ export async function checkAndRunRemoteSchedules(logFn, context = {}) {
|
|
|
285
294
|
log(`Schedule "${schedule.name}": execution error: ${error.message}`);
|
|
286
295
|
}
|
|
287
296
|
|
|
288
|
-
// 3. Advance next_run_at (
|
|
297
|
+
// 3. Advance next_run_at only if action succeeded (prevents silent lost fires)
|
|
298
|
+
if (!actionSucceeded) {
|
|
299
|
+
log(`Schedule "${schedule.name}": action failed, will retry next cycle`);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
289
303
|
const now = new Date();
|
|
290
304
|
const scheduleConfig = { type: schedule.schedule_type, value: schedule.schedule_value };
|
|
291
305
|
|
package/lib/daemon.js
CHANGED
|
@@ -550,85 +550,6 @@ async function fetchQueuedTasks() {
|
|
|
550
550
|
}
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
-
// ==================== Scheduled Reminder Bridge ====================
|
|
554
|
-
|
|
555
|
-
async function fetchScheduledTodos() {
|
|
556
|
-
try {
|
|
557
|
-
const machineId = getMachineId();
|
|
558
|
-
const params = new URLSearchParams();
|
|
559
|
-
params.set('scheduled_before', new Date().toISOString());
|
|
560
|
-
if (machineId) {
|
|
561
|
-
params.set('machine_id', machineId);
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// Include registered git_remotes so backend can scope to this machine's projects
|
|
565
|
-
const projects = getListedProjects();
|
|
566
|
-
const gitRemotes = Object.keys(projects);
|
|
567
|
-
const headers = {};
|
|
568
|
-
if (machineId && gitRemotes.length > 0) {
|
|
569
|
-
headers['X-Machine-Id'] = machineId;
|
|
570
|
-
headers['X-Git-Remotes'] = gitRemotes.join(',');
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
const response = await apiRequest(`synced-todos?${params}`, { headers });
|
|
574
|
-
if (!response.ok) return [];
|
|
575
|
-
|
|
576
|
-
const data = await response.json();
|
|
577
|
-
return data.todos || [];
|
|
578
|
-
} catch (error) {
|
|
579
|
-
logError(`Failed to fetch scheduled todos: ${error.message}`);
|
|
580
|
-
return [];
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
async function checkAndQueueScheduledTodos() {
|
|
585
|
-
const scheduledTodos = await fetchScheduledTodos();
|
|
586
|
-
if (scheduledTodos.length === 0) return;
|
|
587
|
-
|
|
588
|
-
const registeredProjects = getListedProjects();
|
|
589
|
-
|
|
590
|
-
for (const todo of scheduledTodos) {
|
|
591
|
-
const dn = todo.displayNumber || todo.display_number;
|
|
592
|
-
const todoId = todo.id;
|
|
593
|
-
|
|
594
|
-
// Skip tasks already in an execution state (running, queued, completed, failed, etc.)
|
|
595
|
-
const execStatus = todo.executionStatus || todo.execution_status;
|
|
596
|
-
if (execStatus && execStatus !== 'none') {
|
|
597
|
-
continue;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
// Skip completed tasks
|
|
601
|
-
if (todo.isCompleted || todo.is_completed) {
|
|
602
|
-
continue;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
// Skip tasks whose project is not registered on this machine
|
|
606
|
-
const gitRemote = todo.gitRemote || todo.git_remote;
|
|
607
|
-
if (gitRemote && Object.keys(registeredProjects).length > 0) {
|
|
608
|
-
if (!(gitRemote in registeredProjects)) {
|
|
609
|
-
log(`Schedule skipped #${dn}: project ${gitRemote} not registered on this machine`);
|
|
610
|
-
continue;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
log(`Schedule triggered for #${dn} (reminder_date: ${todo.reminderDate || todo.reminder_date})`);
|
|
615
|
-
|
|
616
|
-
try {
|
|
617
|
-
await updateTaskStatus(dn, 'queued', {
|
|
618
|
-
event: {
|
|
619
|
-
type: 'scheduled_trigger',
|
|
620
|
-
timestamp: new Date().toISOString(),
|
|
621
|
-
summary: 'Auto-queued: reminder time reached',
|
|
622
|
-
}
|
|
623
|
-
}, todoId);
|
|
624
|
-
|
|
625
|
-
log(`Queued #${dn} via schedule trigger`);
|
|
626
|
-
} catch (error) {
|
|
627
|
-
logError(`Failed to queue scheduled todo #${dn}: ${error.message}`);
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
|
|
632
553
|
// ==================== Task Status Updates ====================
|
|
633
554
|
|
|
634
555
|
async function updateTaskStatus(displayNumber, status, extra = {}, todoId = null) {
|
|
@@ -3239,18 +3160,11 @@ async function mainLoop() {
|
|
|
3239
3160
|
try {
|
|
3240
3161
|
await checkTimeouts();
|
|
3241
3162
|
|
|
3242
|
-
// Schedule bridge: auto-queue todos whose reminder time has arrived
|
|
3243
|
-
try {
|
|
3244
|
-
await checkAndQueueScheduledTodos();
|
|
3245
|
-
} catch (error) {
|
|
3246
|
-
logError(`Scheduled todo check error: ${error.message}`);
|
|
3247
|
-
}
|
|
3248
|
-
|
|
3249
3163
|
await pollAndExecute();
|
|
3250
3164
|
|
|
3251
3165
|
// Remote schedules from Supabase
|
|
3252
3166
|
try {
|
|
3253
|
-
await checkAndRunRemoteSchedules(log, { apiRequest });
|
|
3167
|
+
await checkAndRunRemoteSchedules(log, { apiRequest, machineId: getMachineId() });
|
|
3254
3168
|
} catch (error) {
|
|
3255
3169
|
logError(`Remote schedules error: ${error.message}`);
|
|
3256
3170
|
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reminder parser for Push CLI.
|
|
3
|
+
*
|
|
4
|
+
* Ports the iOS three-tier agent scheduling model to JavaScript.
|
|
5
|
+
* Source of truth: App/Data/TodoItem+ReminderParsing.swift
|
|
6
|
+
*
|
|
7
|
+
* Three tiers:
|
|
8
|
+
* Tier 1 — ASAP: "today", "now", "asap" → now + 1 minute
|
|
9
|
+
* Tier 2 — Deferred: "tomorrow", "next week", "morning" → target day at default hour
|
|
10
|
+
* Tier 3 — Precise: "at 3pm", "in 2 hours" → exact requested time
|
|
11
|
+
*
|
|
12
|
+
* Key difference from iOS: CLI cannot access user-configurable ReminderSettings.
|
|
13
|
+
* Uses factory defaults from ReminderSettings.swift instead.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Fixed defaults matching iOS ReminderSettings factory values
|
|
17
|
+
const DEFAULTS = {
|
|
18
|
+
general: 9, // ReminderSettings.swift:32 — defaultReminderHour
|
|
19
|
+
morning: 9, // ReminderSettings.swift:45 — defaultMorningHour
|
|
20
|
+
evening: 18, // ReminderSettings.swift:53 — defaultEveningHour
|
|
21
|
+
night: 21, // ReminderParsing.swift:223 — hardcoded
|
|
22
|
+
afternoon: 14, // ReminderParsing.swift:200 — hardcoded
|
|
23
|
+
endOfDay: 17, // ReminderParsing.swift:171 — hardcoded
|
|
24
|
+
noon: 12, // ReminderParsing.swift:233 — hardcoded
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse a natural language reminder description into a concrete Date.
|
|
29
|
+
*
|
|
30
|
+
* @param {string|null} description - Natural language like "tomorrow night", "at 3pm"
|
|
31
|
+
* @param {Date} [now] - Current time (for testing). Defaults to new Date().
|
|
32
|
+
* @returns {{ date: Date|null, timeSource: string|null }}
|
|
33
|
+
*/
|
|
34
|
+
export function parseReminder(description, now = new Date()) {
|
|
35
|
+
if (!description || typeof description !== 'string') {
|
|
36
|
+
return { date: null, timeSource: null };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const text = description.toLowerCase().trim();
|
|
40
|
+
if (!text) {
|
|
41
|
+
return { date: null, timeSource: null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Tier 1 anchor: 1-minute buffer so reminderDate is always > now after sync latency.
|
|
45
|
+
const asapDate = new Date(now.getTime() + 60_000);
|
|
46
|
+
|
|
47
|
+
// ============================================================
|
|
48
|
+
// PHASE 1: Precise relative patterns (Tier 3)
|
|
49
|
+
// "in X minutes/hours" — inherently future, no rollover needed.
|
|
50
|
+
// "in X days" — day-level, uses defaultReminderHour on target day.
|
|
51
|
+
// ============================================================
|
|
52
|
+
|
|
53
|
+
if (text.startsWith('in ') || text.includes('half an hour') || text.includes('half hour')) {
|
|
54
|
+
if (text.includes('half an hour') || text.includes('half hour')) {
|
|
55
|
+
return { date: addMinutes(now, 30), timeSource: 'userSpecified' };
|
|
56
|
+
}
|
|
57
|
+
if (text.includes('an hour') && !text.includes('half')) {
|
|
58
|
+
return { date: addHours(now, 1), timeSource: 'userSpecified' };
|
|
59
|
+
}
|
|
60
|
+
if (text.includes('minute')) {
|
|
61
|
+
const n = extractNumber(text);
|
|
62
|
+
if (n) return { date: addMinutes(now, n), timeSource: 'userSpecified' };
|
|
63
|
+
}
|
|
64
|
+
if (text.includes('hour')) {
|
|
65
|
+
const n = extractNumber(text);
|
|
66
|
+
if (n) return { date: addHours(now, n), timeSource: 'userSpecified' };
|
|
67
|
+
}
|
|
68
|
+
if (text.includes('day')) {
|
|
69
|
+
const n = extractNumber(text);
|
|
70
|
+
if (n) {
|
|
71
|
+
const future = addDays(now, n);
|
|
72
|
+
return { date: setTime(future, DEFAULTS.general, 0), timeSource: 'defaultGeneral' };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ============================================================
|
|
78
|
+
// PHASE 2: ASAP (Tier 1) — execute on next daemon poll
|
|
79
|
+
// ============================================================
|
|
80
|
+
|
|
81
|
+
if (text.includes('right now') || text === 'now' ||
|
|
82
|
+
text.includes('asap') || text === 'soon' ||
|
|
83
|
+
text.includes('immediately')) {
|
|
84
|
+
return { date: asapDate, timeSource: 'userSpecified' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (text === 'today') {
|
|
88
|
+
return { date: asapDate, timeSource: 'userSpecified' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (text.includes('later today') || text.includes('later on')) {
|
|
92
|
+
return { date: asapDate, timeSource: 'userSpecified' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ============================================================
|
|
96
|
+
// PHASE 3: End-of-day expressions (Tier 2)
|
|
97
|
+
// ============================================================
|
|
98
|
+
|
|
99
|
+
if (text.includes('end of day') || text.includes('eod') ||
|
|
100
|
+
text.includes('close of business') || text.includes('cob') ||
|
|
101
|
+
text.includes('by end of')) {
|
|
102
|
+
const baseDate = extractBaseDate(text, now);
|
|
103
|
+
const eodDate = setTime(baseDate, DEFAULTS.endOfDay, 0);
|
|
104
|
+
return { date: ensureFutureTime(eodDate, now), timeSource: 'userSpecified' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ============================================================
|
|
108
|
+
// PHASE 4: Time-of-day patterns (Tier 2, with day-anchor awareness)
|
|
109
|
+
// ============================================================
|
|
110
|
+
|
|
111
|
+
if (text.includes('morning')) {
|
|
112
|
+
const baseDate = extractBaseDate(text, now);
|
|
113
|
+
const morningDate = setTime(baseDate, DEFAULTS.morning, 0);
|
|
114
|
+
if (isDayAnchored(text) && morningDate <= now) {
|
|
115
|
+
return { date: asapDate, timeSource: 'userSpecified' };
|
|
116
|
+
}
|
|
117
|
+
return { date: ensureFutureTime(morningDate, now), timeSource: 'defaultMorning' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (text.includes('afternoon')) {
|
|
121
|
+
const baseDate = extractBaseDate(text, now);
|
|
122
|
+
const afternoonDate = setTime(baseDate, DEFAULTS.afternoon, 0);
|
|
123
|
+
if (isDayAnchored(text) && afternoonDate <= now) {
|
|
124
|
+
return { date: asapDate, timeSource: 'userSpecified' };
|
|
125
|
+
}
|
|
126
|
+
return { date: ensureFutureTime(afternoonDate, now), timeSource: 'userSpecified' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (text.includes('evening') || text.includes('tonight')) {
|
|
130
|
+
const baseDate = extractBaseDate(text, now);
|
|
131
|
+
const eveningDate = setTime(baseDate, DEFAULTS.evening, 0);
|
|
132
|
+
if (isDayAnchored(text) && eveningDate <= now) {
|
|
133
|
+
return { date: asapDate, timeSource: 'defaultEvening' };
|
|
134
|
+
}
|
|
135
|
+
return { date: ensureFutureTime(eveningDate, now), timeSource: 'defaultEvening' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// "night" without "tonight" (checked after evening/tonight to avoid double-match)
|
|
139
|
+
if (text.includes('night') && !text.includes('tonight')) {
|
|
140
|
+
const baseDate = extractBaseDate(text, now);
|
|
141
|
+
const nightDate = setTime(baseDate, DEFAULTS.night, 0);
|
|
142
|
+
if (isDayAnchored(text) && nightDate <= now) {
|
|
143
|
+
return { date: asapDate, timeSource: 'userSpecified' };
|
|
144
|
+
}
|
|
145
|
+
return { date: ensureFutureTime(nightDate, now), timeSource: 'userSpecified' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (text.includes('noon') || text.includes('midday')) {
|
|
149
|
+
const baseDate = extractBaseDate(text, now);
|
|
150
|
+
const noonDate = setTime(baseDate, DEFAULTS.noon, 0);
|
|
151
|
+
if (isDayAnchored(text) && noonDate <= now) {
|
|
152
|
+
return { date: asapDate, timeSource: 'userSpecified' };
|
|
153
|
+
}
|
|
154
|
+
return { date: ensureFutureTime(noonDate, now), timeSource: 'userSpecified' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ============================================================
|
|
158
|
+
// PHASE 5: Specific time patterns (Tier 3 — Precise)
|
|
159
|
+
// ============================================================
|
|
160
|
+
|
|
161
|
+
const time = extractTime(text, now);
|
|
162
|
+
if (time) {
|
|
163
|
+
const baseDate = extractBaseDate(text, now);
|
|
164
|
+
const specificDate = setTime(baseDate, time.hour, time.minute);
|
|
165
|
+
return { date: ensureFutureTime(specificDate, now), timeSource: 'userSpecified' };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ============================================================
|
|
169
|
+
// PHASE 6: Deferred day references (Tier 2)
|
|
170
|
+
// Note: compound forms like "tomorrow morning" are caught in Phase 4.
|
|
171
|
+
// ============================================================
|
|
172
|
+
|
|
173
|
+
if (text.includes('tomorrow')) {
|
|
174
|
+
const tomorrow = addDays(now, 1);
|
|
175
|
+
return { date: setTime(tomorrow, DEFAULTS.general, 0), timeSource: 'defaultGeneral' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (text.includes('next week')) {
|
|
179
|
+
const nextWeek = addDays(now, 7);
|
|
180
|
+
return { date: setTime(nextWeek, DEFAULTS.general, 0), timeSource: 'defaultGeneral' };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (text.includes('this week')) {
|
|
184
|
+
const friday = thisFriday(now, DEFAULTS.general);
|
|
185
|
+
if (friday === null) {
|
|
186
|
+
// It's Saturday — this week's Friday is yesterday
|
|
187
|
+
return { date: asapDate, timeSource: 'defaultGeneral' };
|
|
188
|
+
}
|
|
189
|
+
if (friday <= now) {
|
|
190
|
+
return { date: asapDate, timeSource: 'defaultGeneral' };
|
|
191
|
+
}
|
|
192
|
+
return { date: friday, timeSource: 'defaultGeneral' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Weekday names
|
|
196
|
+
const weekdays = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
197
|
+
for (let i = 0; i < weekdays.length; i++) {
|
|
198
|
+
if (text.includes(weekdays[i])) {
|
|
199
|
+
const currentWeekday = now.getDay(); // 0=Sunday
|
|
200
|
+
|
|
201
|
+
if (currentWeekday === i) {
|
|
202
|
+
// Today IS the named weekday
|
|
203
|
+
const weekdayTime = extractTime(text, now);
|
|
204
|
+
if (weekdayTime) {
|
|
205
|
+
const specificDate = setTime(now, weekdayTime.hour, weekdayTime.minute);
|
|
206
|
+
return { date: ensureFutureTime(specificDate, now), timeSource: 'userSpecified' };
|
|
207
|
+
}
|
|
208
|
+
// No time specified → ASAP (user means "this [weekday]" = today)
|
|
209
|
+
return { date: asapDate, timeSource: 'userSpecified' };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Different weekday → next occurrence
|
|
213
|
+
const nextDate = nextWeekday(i, now);
|
|
214
|
+
if (nextDate) {
|
|
215
|
+
const weekdayTime = extractTime(text, now);
|
|
216
|
+
if (weekdayTime) {
|
|
217
|
+
return { date: setTime(nextDate, weekdayTime.hour, weekdayTime.minute), timeSource: 'userSpecified' };
|
|
218
|
+
}
|
|
219
|
+
return { date: setTime(nextDate, DEFAULTS.general, 0), timeSource: 'defaultGeneral' };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// No pattern matched
|
|
225
|
+
return { date: null, timeSource: null };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ==================== Helper Functions ====================
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Extract a positive integer from text. Handles digits and word numbers.
|
|
232
|
+
* Port of iOS extractNumber(from:)
|
|
233
|
+
*/
|
|
234
|
+
function extractNumber(text) {
|
|
235
|
+
// Try numeric digits
|
|
236
|
+
const digits = text.replace(/[^\d]/g, '');
|
|
237
|
+
if (digits) {
|
|
238
|
+
const n = parseInt(digits, 10);
|
|
239
|
+
if (n > 0) return n;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Word numbers
|
|
243
|
+
const wordNumbers = {
|
|
244
|
+
'one': 1, 'two': 2, 'three': 3, 'four': 4, 'five': 5,
|
|
245
|
+
'six': 6, 'seven': 7, 'eight': 8, 'nine': 9, 'ten': 10,
|
|
246
|
+
'fifteen': 15, 'twenty': 20, 'thirty': 30, 'forty five': 45,
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
for (const [word, number] of Object.entries(wordNumbers)) {
|
|
250
|
+
if (text.includes(word)) return number;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Extract hour:minute from time expressions.
|
|
258
|
+
* Port of iOS extractTime(from:now:)
|
|
259
|
+
*/
|
|
260
|
+
function extractTime(text, now = new Date()) {
|
|
261
|
+
// Pattern 1: X:XX am/pm or X:XX p.m.
|
|
262
|
+
const colonAmPm = text.match(/(\d{1,2}):(\d{2})\s*(a\.?m\.?|p\.?m\.?)/i);
|
|
263
|
+
if (colonAmPm) {
|
|
264
|
+
let hour = parseInt(colonAmPm[1], 10);
|
|
265
|
+
const minute = parseInt(colonAmPm[2], 10);
|
|
266
|
+
const period = colonAmPm[3].toLowerCase();
|
|
267
|
+
if (period.startsWith('p') && hour < 12) hour += 12;
|
|
268
|
+
if (period.startsWith('a') && hour === 12) hour = 0;
|
|
269
|
+
if (hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) {
|
|
270
|
+
return { hour, minute };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Pattern 2: X am/pm or X p.m.
|
|
275
|
+
const bareAmPm = text.match(/(\d{1,2})\s*(a\.?m\.?|p\.?m\.?)/i);
|
|
276
|
+
if (bareAmPm) {
|
|
277
|
+
let hour = parseInt(bareAmPm[1], 10);
|
|
278
|
+
const period = bareAmPm[2].toLowerCase();
|
|
279
|
+
if (period.startsWith('p') && hour < 12) hour += 12;
|
|
280
|
+
if (period.startsWith('a') && hour === 12) hour = 0;
|
|
281
|
+
if (hour >= 0 && hour <= 23) {
|
|
282
|
+
return { hour, minute: 0 };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Pattern 3: 24-hour format X:XX (no am/pm)
|
|
287
|
+
const twentyFour = text.match(/(\d{1,2}):(\d{2})(?!\s*(a|p))/i);
|
|
288
|
+
if (twentyFour) {
|
|
289
|
+
const hour = parseInt(twentyFour[1], 10);
|
|
290
|
+
const minute = parseInt(twentyFour[2], 10);
|
|
291
|
+
if (hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) {
|
|
292
|
+
return { hour, minute };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Pattern 4: Bare number with "at" prefix — infer AM/PM from current time
|
|
297
|
+
const bareNumber = text.match(/(?:at\s+)?(\d{1,2})(?:\s*o'?clock)?/i);
|
|
298
|
+
if (bareNumber) {
|
|
299
|
+
const num = parseInt(bareNumber[1], 10);
|
|
300
|
+
if (num >= 1 && num <= 12) {
|
|
301
|
+
const currentHour = now.getHours();
|
|
302
|
+
const amHour = num === 12 ? 0 : num;
|
|
303
|
+
const pmHour = num === 12 ? 12 : num + 12;
|
|
304
|
+
|
|
305
|
+
let inferredHour;
|
|
306
|
+
if (currentHour < amHour) {
|
|
307
|
+
inferredHour = amHour;
|
|
308
|
+
} else if (currentHour < pmHour) {
|
|
309
|
+
inferredHour = pmHour;
|
|
310
|
+
} else {
|
|
311
|
+
inferredHour = amHour; // Both passed → AM tomorrow (ensureFutureTime will roll)
|
|
312
|
+
}
|
|
313
|
+
return { hour: inferredHour, minute: 0 };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Extract the base date from compound expressions.
|
|
322
|
+
* Port of iOS extractBaseDate(from:calendar:now:)
|
|
323
|
+
*/
|
|
324
|
+
function extractBaseDate(text, now) {
|
|
325
|
+
if (text.includes('tomorrow')) {
|
|
326
|
+
return addDays(now, 1);
|
|
327
|
+
}
|
|
328
|
+
if (text.includes('today') || text.includes('tonight') || text.includes('this')) {
|
|
329
|
+
return now;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const weekdays = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
333
|
+
for (let i = 0; i < weekdays.length; i++) {
|
|
334
|
+
if (text.includes(weekdays[i])) {
|
|
335
|
+
return nextWeekday(i, now) || now;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return now;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Check if the description is explicitly pinned to this calendar day.
|
|
344
|
+
* Port of iOS isDayAnchored(_:)
|
|
345
|
+
*/
|
|
346
|
+
function isDayAnchored(text) {
|
|
347
|
+
if (text.includes('tonight')) return true;
|
|
348
|
+
|
|
349
|
+
const phrases = [
|
|
350
|
+
'this morning', 'this afternoon', 'this evening', 'this night', 'this noon',
|
|
351
|
+
'today morning', 'today afternoon', 'today evening', 'today night', 'today noon',
|
|
352
|
+
];
|
|
353
|
+
return phrases.some(p => text.includes(p));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Returns this week's Friday at the given hour, or null if it's Saturday.
|
|
358
|
+
* Port of iOS thisFriday(from:calendar:defaultHour:)
|
|
359
|
+
*/
|
|
360
|
+
function thisFriday(now, defaultHour) {
|
|
361
|
+
const fridayWeekday = 5; // JS: 0=Sun, 5=Fri
|
|
362
|
+
const currentWeekday = now.getDay();
|
|
363
|
+
const daysToFriday = fridayWeekday - currentWeekday;
|
|
364
|
+
|
|
365
|
+
if (daysToFriday < 0) {
|
|
366
|
+
// It's Saturday — this week's Friday is yesterday
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
371
|
+
const targetDay = addDays(startOfDay, daysToFriday);
|
|
372
|
+
return setTime(targetDay, defaultHour, 0);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Returns the next occurrence of the given weekday.
|
|
377
|
+
* Port of iOS nextWeekday(_:from:)
|
|
378
|
+
*/
|
|
379
|
+
function nextWeekday(weekday, now) {
|
|
380
|
+
const currentWeekday = now.getDay();
|
|
381
|
+
let daysToAdd = weekday - currentWeekday;
|
|
382
|
+
if (daysToAdd <= 0) {
|
|
383
|
+
daysToAdd += 7;
|
|
384
|
+
}
|
|
385
|
+
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
386
|
+
return addDays(startOfDay, daysToAdd);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Rolls a past time to the next day at the same time.
|
|
391
|
+
* Port of iOS ensureFutureTime(_:now:calendar:)
|
|
392
|
+
*/
|
|
393
|
+
function ensureFutureTime(date, now) {
|
|
394
|
+
if (date > now) return date;
|
|
395
|
+
return addDays(date, 1);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ==================== Date Utilities ====================
|
|
399
|
+
|
|
400
|
+
function addMinutes(date, minutes) {
|
|
401
|
+
return new Date(date.getTime() + minutes * 60_000);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function addHours(date, hours) {
|
|
405
|
+
return new Date(date.getTime() + hours * 3_600_000);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function addDays(date, days) {
|
|
409
|
+
const result = new Date(date);
|
|
410
|
+
result.setDate(result.getDate() + days);
|
|
411
|
+
return result;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function setTime(date, hour, minute) {
|
|
415
|
+
const result = new Date(date);
|
|
416
|
+
result.setHours(hour, minute, 0, 0);
|
|
417
|
+
return result;
|
|
418
|
+
}
|