@masslessai/push-todo 4.3.0 → 4.4.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 +65 -3
- 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
|
|
@@ -178,6 +184,10 @@ const options = {
|
|
|
178
184
|
'store-e2ee-key': { type: 'string' },
|
|
179
185
|
'description': { type: 'string' },
|
|
180
186
|
'auto': { type: 'boolean' },
|
|
187
|
+
// Create command options
|
|
188
|
+
'remind': { type: 'string' },
|
|
189
|
+
'remind-at': { type: 'string' },
|
|
190
|
+
'alarm': { type: 'boolean' },
|
|
181
191
|
// Confirm command options
|
|
182
192
|
'type': { type: 'string' },
|
|
183
193
|
'title': { type: 'string' },
|
|
@@ -684,22 +694,74 @@ export async function run(argv) {
|
|
|
684
694
|
console.error('');
|
|
685
695
|
console.error('Usage:');
|
|
686
696
|
console.error(' push-todo create "Fix the auth bug"');
|
|
687
|
-
console.error(' push-todo create "
|
|
688
|
-
console.error(' push-todo create "
|
|
697
|
+
console.error(' push-todo create "Debug daemon" --remind "tomorrow night"');
|
|
698
|
+
console.error(' push-todo create "Call dentist" --remind "at 3pm" --alarm');
|
|
699
|
+
console.error(' push-todo create "Deploy" --remind-at "2026-03-03T14:00:00"');
|
|
689
700
|
process.exit(1);
|
|
690
701
|
}
|
|
691
702
|
|
|
703
|
+
// Conflict check
|
|
704
|
+
if (values.remind && values['remind-at']) {
|
|
705
|
+
console.error(red('Error: Use --remind OR --remind-at, not both.'));
|
|
706
|
+
process.exit(1);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Parse reminder
|
|
710
|
+
let reminderDate = null;
|
|
711
|
+
let reminderTimeSource = null;
|
|
712
|
+
const alarmEnabled = values.alarm || false;
|
|
713
|
+
|
|
714
|
+
if (values['remind-at']) {
|
|
715
|
+
const parsed = new Date(values['remind-at']);
|
|
716
|
+
if (isNaN(parsed.getTime())) {
|
|
717
|
+
console.error(red('Error: --remind-at must be a valid ISO8601 date.'));
|
|
718
|
+
console.error(dim(' Example: 2026-03-03T14:00:00'));
|
|
719
|
+
process.exit(1);
|
|
720
|
+
}
|
|
721
|
+
if (parsed <= new Date()) {
|
|
722
|
+
console.error(red('Error: --remind-at must be in the future.'));
|
|
723
|
+
process.exit(1);
|
|
724
|
+
}
|
|
725
|
+
reminderDate = parsed.toISOString();
|
|
726
|
+
reminderTimeSource = 'userSpecified';
|
|
727
|
+
} else if (values.remind) {
|
|
728
|
+
const result = parseReminder(values.remind);
|
|
729
|
+
if (!result.date) {
|
|
730
|
+
console.error(red(`Error: Could not parse reminder: "${values.remind}"`));
|
|
731
|
+
console.error('');
|
|
732
|
+
console.error('Examples:');
|
|
733
|
+
console.error(' --remind "tomorrow"');
|
|
734
|
+
console.error(' --remind "tonight"');
|
|
735
|
+
console.error(' --remind "in 2 hours"');
|
|
736
|
+
console.error(' --remind "next monday at 3pm"');
|
|
737
|
+
process.exit(1);
|
|
738
|
+
}
|
|
739
|
+
reminderDate = result.date.toISOString();
|
|
740
|
+
reminderTimeSource = result.timeSource;
|
|
741
|
+
}
|
|
742
|
+
|
|
692
743
|
try {
|
|
693
744
|
const todo = await api.createTodo({
|
|
694
745
|
title,
|
|
695
746
|
content: values.content || null,
|
|
696
747
|
backlog: values.backlog || false,
|
|
748
|
+
reminderDate,
|
|
749
|
+
reminderTimeSource,
|
|
750
|
+
alarmEnabled,
|
|
697
751
|
});
|
|
698
752
|
|
|
699
753
|
if (values.json) {
|
|
700
754
|
console.log(JSON.stringify(todo, null, 2));
|
|
701
755
|
} else {
|
|
702
|
-
|
|
756
|
+
let msg = green(`Created todo #${todo.displayNumber}: ${todo.title}`);
|
|
757
|
+
if (reminderDate) {
|
|
758
|
+
const d = new Date(reminderDate);
|
|
759
|
+
msg += `\n ${dim('Reminder:')} ${d.toLocaleString()}`;
|
|
760
|
+
}
|
|
761
|
+
if (alarmEnabled) {
|
|
762
|
+
msg += ` ${dim('(urgent)')}`;
|
|
763
|
+
}
|
|
764
|
+
console.log(msg);
|
|
703
765
|
}
|
|
704
766
|
} catch (error) {
|
|
705
767
|
console.error(red(`Failed to create todo: ${error.message}`));
|
|
@@ -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
|
+
}
|