@masslessai/push-todo 4.2.9 → 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/SKILL.md +5 -0
- package/lib/api.js +10 -3
- package/lib/cli.js +230 -71
- package/lib/context-engine.js +6 -0
- package/lib/cron.js +104 -221
- package/lib/daemon.js +26 -130
- package/lib/reminder-parser.js +418 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|