@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 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
- * @returns {Promise<Object>} Created todo with { id, displayNumber, title, createdAt }
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 "Fix the auth bug" --backlog');
688
- console.error(' push-todo create "Fix the auth bug" --content "Detailed description"');
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
- console.log(green(`Created todo #${todo.displayNumber}: ${todo.title}`));
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@masslessai/push-todo",
3
- "version": "4.3.0",
3
+ "version": "4.4.0",
4
4
  "description": "Voice tasks from Push iOS app for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {