@tuturuuu/ai 0.0.10

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.
Files changed (130) hide show
  1. package/README.md +76 -0
  2. package/package.json +106 -0
  3. package/src/api-key-hash.ts +28 -0
  4. package/src/calendar/events.ts +34 -0
  5. package/src/calendar/route.ts +114 -0
  6. package/src/chat/credit-source.ts +1 -0
  7. package/src/chat/google/chat-request-schema.ts +150 -0
  8. package/src/chat/google/default-system-instruction.ts +198 -0
  9. package/src/chat/google/message-file-processing.ts +212 -0
  10. package/src/chat/google/mira-step-preparation.ts +221 -0
  11. package/src/chat/google/new/route.ts +368 -0
  12. package/src/chat/google/route-auth.ts +81 -0
  13. package/src/chat/google/route-chat-resolution.ts +98 -0
  14. package/src/chat/google/route-credits.ts +61 -0
  15. package/src/chat/google/route-message-preparation.ts +331 -0
  16. package/src/chat/google/route-mira-runtime.ts +206 -0
  17. package/src/chat/google/route.ts +632 -0
  18. package/src/chat/google/stream-finish-persistence.ts +722 -0
  19. package/src/chat/google/summary/route.ts +153 -0
  20. package/src/chat/mira-render-ui-policy.ts +540 -0
  21. package/src/chat/mira-system-instruction.ts +484 -0
  22. package/src/chat-sdk/adapters.ts +389 -0
  23. package/src/chat-sdk/registry.ts +197 -0
  24. package/src/chat-sdk.ts +33 -0
  25. package/src/core.ts +3 -0
  26. package/src/credits/cap-output-tokens.ts +90 -0
  27. package/src/credits/check-credits.ts +232 -0
  28. package/src/credits/constants.ts +30 -0
  29. package/src/credits/index.ts +46 -0
  30. package/src/credits/model-mapping.ts +92 -0
  31. package/src/credits/reservations.ts +514 -0
  32. package/src/credits/resolve-plan-model.ts +219 -0
  33. package/src/credits/sync-gateway-models.ts +351 -0
  34. package/src/credits/types.ts +109 -0
  35. package/src/credits/use-ai-credits.ts +3 -0
  36. package/src/embeddings/metered.ts +283 -0
  37. package/src/executions/route.ts +137 -0
  38. package/src/generate/route.ts +411 -0
  39. package/src/hooks.ts +7 -0
  40. package/src/meetings/summary/route.ts +7 -0
  41. package/src/meetings/transcription/route.ts +134 -0
  42. package/src/memory/client.ts +158 -0
  43. package/src/memory/config.ts +38 -0
  44. package/src/memory/index.ts +32 -0
  45. package/src/memory/ingest.ts +51 -0
  46. package/src/memory/middleware.ts +35 -0
  47. package/src/memory/operations.ts +480 -0
  48. package/src/memory/scope.ts +102 -0
  49. package/src/memory/settings.ts +121 -0
  50. package/src/memory/types.ts +101 -0
  51. package/src/memory/workspace.ts +36 -0
  52. package/src/memory.ts +1 -0
  53. package/src/mind/patch.ts +146 -0
  54. package/src/mind/route.ts +687 -0
  55. package/src/mind/tools.ts +1500 -0
  56. package/src/mind/types.ts +20 -0
  57. package/src/object/core.ts +3 -0
  58. package/src/object/flashcards/route.ts +140 -0
  59. package/src/object/quizzes/explanation/route.ts +145 -0
  60. package/src/object/quizzes/route.ts +142 -0
  61. package/src/object/types.ts +187 -0
  62. package/src/object/year-plan/route.ts +196 -0
  63. package/src/react.ts +1 -0
  64. package/src/scheduling/algorithm.ts +791 -0
  65. package/src/scheduling/default.ts +36 -0
  66. package/src/scheduling/duration-optimizer.ts +689 -0
  67. package/src/scheduling/index.ts +79 -0
  68. package/src/scheduling/priority-calculator.ts +187 -0
  69. package/src/scheduling/recurrence-calculator.ts +621 -0
  70. package/src/scheduling/templates.ts +892 -0
  71. package/src/scheduling/types.ts +136 -0
  72. package/src/scheduling/web-adapter.ts +308 -0
  73. package/src/scheduling.ts +6 -0
  74. package/src/supported-actions.ts +1 -0
  75. package/src/supported-providers.ts +6 -0
  76. package/src/tools/context-builder.ts +372 -0
  77. package/src/tools/core.ts +1 -0
  78. package/src/tools/definitions/calendar.ts +106 -0
  79. package/src/tools/definitions/finance.ts +197 -0
  80. package/src/tools/definitions/image.ts +74 -0
  81. package/src/tools/definitions/memory.ts +83 -0
  82. package/src/tools/definitions/meta.ts +154 -0
  83. package/src/tools/definitions/render-ui.ts +81 -0
  84. package/src/tools/definitions/tasks.ts +343 -0
  85. package/src/tools/definitions/time-tracking.ts +381 -0
  86. package/src/tools/definitions/workspace-context.ts +45 -0
  87. package/src/tools/definitions/workspace-user-chat.ts +111 -0
  88. package/src/tools/executors/calendar.ts +371 -0
  89. package/src/tools/executors/chat.ts +15 -0
  90. package/src/tools/executors/finance.ts +638 -0
  91. package/src/tools/executors/helpers/encryption.ts +107 -0
  92. package/src/tools/executors/image.ts +247 -0
  93. package/src/tools/executors/markitdown.ts +684 -0
  94. package/src/tools/executors/memory.ts +277 -0
  95. package/src/tools/executors/parallel-checks.ts +176 -0
  96. package/src/tools/executors/qr.ts +170 -0
  97. package/src/tools/executors/scope-helpers.ts +192 -0
  98. package/src/tools/executors/search.ts +149 -0
  99. package/src/tools/executors/settings.ts +40 -0
  100. package/src/tools/executors/tasks.ts +1087 -0
  101. package/src/tools/executors/theme.ts +23 -0
  102. package/src/tools/executors/timer/timer-categories-executor.ts +110 -0
  103. package/src/tools/executors/timer/timer-category-mutations.ts +240 -0
  104. package/src/tools/executors/timer/timer-goal-mutations.ts +323 -0
  105. package/src/tools/executors/timer/timer-goals-executor.ts +272 -0
  106. package/src/tools/executors/timer/timer-helpers.ts +372 -0
  107. package/src/tools/executors/timer/timer-mutation-schemas.ts +160 -0
  108. package/src/tools/executors/timer/timer-mutation-types.ts +212 -0
  109. package/src/tools/executors/timer/timer-mutations.ts +19 -0
  110. package/src/tools/executors/timer/timer-queries.ts +18 -0
  111. package/src/tools/executors/timer/timer-session-lifecycle.ts +299 -0
  112. package/src/tools/executors/timer/timer-session-mutations.ts +10 -0
  113. package/src/tools/executors/timer/timer-session-queries.ts +153 -0
  114. package/src/tools/executors/timer/timer-session-updates.ts +200 -0
  115. package/src/tools/executors/timer/timer-sessions-executor.ts +91 -0
  116. package/src/tools/executors/timer/timer-stats-executor.ts +157 -0
  117. package/src/tools/executors/timer.ts +22 -0
  118. package/src/tools/executors/user.ts +60 -0
  119. package/src/tools/executors/workspace.ts +135 -0
  120. package/src/tools/json-render-catalog.ts +875 -0
  121. package/src/tools/mira-tool-definitions.ts +55 -0
  122. package/src/tools/mira-tool-dispatcher.ts +265 -0
  123. package/src/tools/mira-tool-metadata.ts +164 -0
  124. package/src/tools/mira-tool-names.ts +95 -0
  125. package/src/tools/mira-tool-render-ui.ts +54 -0
  126. package/src/tools/mira-tool-types.ts +17 -0
  127. package/src/tools/mira-tools.ts +167 -0
  128. package/src/tools/normalize-render-ui-input.ts +321 -0
  129. package/src/tools/workspace-context.ts +233 -0
  130. package/src/types.ts +38 -0
@@ -0,0 +1,791 @@
1
+ import type { TaskPriority } from '@tuturuuu/types/primitives/Priority';
2
+ import dayjs from 'dayjs';
3
+ import minMax from 'dayjs/plugin/minMax';
4
+ import { v4 as uuidv4 } from 'uuid';
5
+ import { defaultActiveHours } from './default';
6
+ import type {
7
+ ActiveHours,
8
+ DateRange,
9
+ EnergyProfile,
10
+ Event,
11
+ Log,
12
+ ScheduleResult,
13
+ SchedulingSettings,
14
+ Task,
15
+ TimeOfDayPreference,
16
+ } from './types';
17
+
18
+ /**
19
+ * Extended task type used internally for scheduling with additional tracking fields
20
+ */
21
+ interface TaskPoolItem extends Task {
22
+ remaining: number;
23
+ nextPart: number;
24
+ scheduledParts: number;
25
+ priorityScore: number;
26
+ }
27
+
28
+ /**
29
+ * Check if a given time matches the user's time of day preference
30
+ */
31
+ function matchesTimePreference(
32
+ time: dayjs.Dayjs,
33
+ preference?: TimeOfDayPreference
34
+ ): boolean {
35
+ if (!preference) return true;
36
+
37
+ const hour = time.hour();
38
+ switch (preference) {
39
+ case 'morning':
40
+ return hour >= 6 && hour < 12;
41
+ case 'afternoon':
42
+ return hour >= 12 && hour < 17;
43
+ case 'evening':
44
+ return hour >= 17 && hour < 21;
45
+ case 'night':
46
+ return hour >= 21 || hour < 6;
47
+ default:
48
+ return true;
49
+ }
50
+ }
51
+
52
+ dayjs.extend(minMax);
53
+
54
+ // Helper function to round time to nearest 15-minute increment
55
+ function roundToQuarterHour(
56
+ time: dayjs.Dayjs,
57
+ roundUp: boolean = false
58
+ ): dayjs.Dayjs {
59
+ const minutes = time.minute();
60
+ const remainder = minutes % 15;
61
+
62
+ if (remainder === 0) {
63
+ return time.second(0).millisecond(0);
64
+ }
65
+
66
+ let targetMinute: number;
67
+ if (roundUp) {
68
+ targetMinute = minutes + (15 - remainder);
69
+ } else {
70
+ targetMinute = minutes - remainder;
71
+ }
72
+
73
+ return time.minute(targetMinute).second(0).millisecond(0);
74
+ }
75
+
76
+ // Helper function to convert hours to 15-minute increments
77
+ function hoursToQuarterHours(hours: number): number {
78
+ return Math.round(hours * 4) / 4; // Round to nearest quarter hour
79
+ }
80
+
81
+ // Helper function to ensure duration is at least 15 minutes
82
+ function ensureMinimumDuration(hours: number): number {
83
+ return Math.max(0.25, hoursToQuarterHours(hours)); // Minimum 15 minutes
84
+ }
85
+
86
+ export const scheduleWithFlexibleEvents = (
87
+ flexibleEvents: Event[],
88
+ lockedEvents: Event[],
89
+ activeHours: ActiveHours
90
+ ): ScheduleResult => {
91
+ const now = dayjs();
92
+ const futureFlexibleEvents = flexibleEvents.filter((event) =>
93
+ dayjs(event.range.end).isAfter(now)
94
+ );
95
+
96
+ const futureLockedEvents = lockedEvents.filter((event) =>
97
+ dayjs(event.range.end).isAfter(now)
98
+ );
99
+
100
+ if (flexibleEvents.length !== futureFlexibleEvents.length) {
101
+ console.log(
102
+ `[Scheduler] Skipped ${flexibleEvents.length - futureFlexibleEvents.length} past flexible events.`
103
+ );
104
+ }
105
+ if (lockedEvents.length !== futureLockedEvents.length) {
106
+ console.log(
107
+ `[Scheduler] Skipped ${lockedEvents.length - futureLockedEvents.length} past locked events.`
108
+ );
109
+ }
110
+
111
+ // Now, promote tasks ONLY from the filtered list of future flexible events.
112
+ // We add .filter(Boolean) as a safety measure to remove any potential `null`
113
+ // values if promoteEventToTask is updated to return them.
114
+ const promotedTasks = futureFlexibleEvents
115
+ .map(promoteEventToTask)
116
+ .filter((task): task is Task => task !== null);
117
+
118
+ console.log(
119
+ `[Scheduler] Promoted ${promotedTasks.length} flexible events to tasks.`
120
+ );
121
+
122
+ const result = scheduleTasks(promotedTasks, activeHours, futureLockedEvents);
123
+
124
+ return result;
125
+ };
126
+
127
+ export const prepareTaskChunks = (tasks: Task[]): Task[] => {
128
+ const chunks: Task[] = [];
129
+ for (const task of tasks) {
130
+ if (
131
+ task.allowSplit === false ||
132
+ !task.maxDuration ||
133
+ task.maxDuration <= 0
134
+ ) {
135
+ chunks.push(task);
136
+ continue;
137
+ }
138
+ let remainingDuration = task.duration;
139
+ let partNumber = 1;
140
+ const totalParts = Math.ceil(task.duration / task.maxDuration);
141
+ while (remainingDuration > 0) {
142
+ const partDuration = Math.min(remainingDuration, task.maxDuration);
143
+ chunks.push({
144
+ ...task,
145
+ name:
146
+ totalParts > 1
147
+ ? `${task.name} (Part ${partNumber}/${totalParts})`
148
+ : task.name,
149
+ duration: partDuration,
150
+ minDuration: partDuration,
151
+ priority: task.priority,
152
+ maxDuration: partDuration,
153
+ allowSplit: false,
154
+ });
155
+ remainingDuration -= partDuration;
156
+ partNumber++;
157
+ }
158
+ }
159
+ return chunks;
160
+ };
161
+ // Helper function to calculate task priority score
162
+ function calculatePriorityScore(task: Task): number {
163
+ const now = dayjs();
164
+ let score = 0;
165
+
166
+ // Base priority score (higher = more important)
167
+ const priorityScores: Record<TaskPriority, number> = {
168
+ critical: 1000,
169
+ high: 750,
170
+ normal: 500,
171
+ low: 250,
172
+ };
173
+ score += (priorityScores as Record<string, number>)[task.priority] || 0;
174
+
175
+ // Streak bonus (for habits) - Add 10 points per streak day, capped at 200
176
+ if (task.isHabit && task.streak) {
177
+ score += Math.min(200, task.streak * 10);
178
+ }
179
+
180
+ // Deadline urgency bonus
181
+ if (task.deadline) {
182
+ const deadlineAsDayjs = dayjs(task.deadline);
183
+ const hoursUntilDeadline = deadlineAsDayjs.diff(now, 'hour', true);
184
+
185
+ if (hoursUntilDeadline < 0) {
186
+ // Overdue tasks get maximum urgency
187
+ score += 2000;
188
+ } else if (hoursUntilDeadline < 24) {
189
+ // Due within 24 hours
190
+ score += 1500;
191
+ } else if (hoursUntilDeadline < 72) {
192
+ // Due within 3 days
193
+ score += 1000;
194
+ } else if (hoursUntilDeadline < 168) {
195
+ // Due within a week
196
+ score += 500;
197
+ } else {
198
+ score += 100; // Due later than a week
199
+ }
200
+ }
201
+
202
+ return score;
203
+ }
204
+
205
+ /**
206
+ * Check if a given time is within the user's peak energy window
207
+ */
208
+ function isPeakHour(time: dayjs.Dayjs, profile?: EnergyProfile): boolean {
209
+ if (!profile) return true;
210
+
211
+ const hour = time.hour();
212
+ switch (profile) {
213
+ case 'morning_person':
214
+ return hour >= 8 && hour < 12;
215
+ case 'night_owl':
216
+ return hour >= 20 || hour < 2;
217
+ case 'afternoon_peak':
218
+ return hour >= 13 && hour < 17;
219
+ case 'evening_peak':
220
+ return hour >= 18 && hour < 22;
221
+ default:
222
+ return true;
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Calculate the reason for a task being scheduled at a specific time
228
+ */
229
+ function calculateSchedulingReason(
230
+ task: TaskPoolItem,
231
+ time: dayjs.Dayjs,
232
+ profile?: EnergyProfile
233
+ ): string {
234
+ if (task.energyLoad === 'high' && isPeakHour(time, profile)) {
235
+ return 'Peak energy alignment';
236
+ }
237
+ if (task.priority === 'critical' || task.priority === 'high') {
238
+ return 'Priority prioritization';
239
+ }
240
+ if (task.isHabit && task.streak && task.streak > 0) {
241
+ return `Streak maintenance (${task.streak} days)`;
242
+ }
243
+ if (task.timePreference && matchesTimePreference(time, task.timePreference)) {
244
+ return 'Time preference alignment';
245
+ }
246
+ return 'Available slot';
247
+ }
248
+
249
+ export const promoteEventToTask = (event: Event): Task | null => {
250
+ const start = dayjs(event.range.start);
251
+ const end = dayjs(event.range.end);
252
+
253
+ if (
254
+ !start.isValid() ||
255
+ !end.isValid() ||
256
+ end.isBefore(start) ||
257
+ !event.name
258
+ ) {
259
+ console.warn(`[Promote] Skipping invalid event: ${event.name}`);
260
+ return null;
261
+ }
262
+
263
+ const durationInHours = end.diff(start, 'minute') / 60;
264
+
265
+ // Ensure minimum scheduling duration (0.25h = 15 mins)
266
+ const duration = Math.max(0.25, parseFloat(durationInHours.toFixed(2)));
267
+
268
+ return {
269
+ id: event.id,
270
+ name: event.name,
271
+ duration,
272
+ minDuration: duration,
273
+ maxDuration: duration,
274
+ allowSplit: false,
275
+ category: 'work',
276
+ priority: 'normal',
277
+ deadline: end,
278
+ };
279
+ };
280
+
281
+ export const scheduleTasks = (
282
+ tasks: Task[],
283
+ activeHours: ActiveHours = defaultActiveHours,
284
+ lockedEvents: Event[] = [],
285
+ settings?: {
286
+ energyProfile?: EnergyProfile;
287
+ schedulingSettings?: SchedulingSettings;
288
+ }
289
+ ): ScheduleResult => {
290
+ const scheduledEvents: Event[] = lockedEvents.map((e) => ({
291
+ ...e,
292
+ locked: true,
293
+ }));
294
+ const logs: Log[] = [];
295
+ const minBuffer = settings?.schedulingSettings?.min_buffer || 0;
296
+ let taskPool: TaskPoolItem[] = [];
297
+ try {
298
+ taskPool = tasks.map((task) => ({
299
+ ...task,
300
+ duration: hoursToQuarterHours(task.duration),
301
+ minDuration: ensureMinimumDuration(task.minDuration),
302
+ maxDuration: hoursToQuarterHours(task.maxDuration),
303
+ remaining: hoursToQuarterHours(task.duration),
304
+ nextPart: 1,
305
+ scheduledParts: 0,
306
+ priorityScore: calculatePriorityScore(task),
307
+ }));
308
+ } catch (error) {
309
+ console.error('Error preparing task pool:', error);
310
+ return {
311
+ events: [],
312
+ logs: [{ type: 'error', message: 'Failed to prepare task pool.' }],
313
+ };
314
+ }
315
+ // Sort by priority score (highest first) and then by deadline
316
+ taskPool.sort((a, b) => {
317
+ // First sort by priority score (highest first)
318
+ if (a.priorityScore !== b.priorityScore) {
319
+ return b.priorityScore - a.priorityScore;
320
+ }
321
+
322
+ const aDeadline = a.deadline ? dayjs(a.deadline) : null;
323
+ const bDeadline = b.deadline ? dayjs(b.deadline) : null;
324
+ // If priority scores are equal, sort by deadline (earliest first)
325
+ if (aDeadline && bDeadline) {
326
+ return aDeadline.isBefore(bDeadline) ? -1 : 1;
327
+ }
328
+ if (a.deadline) return -1;
329
+ if (b.deadline) return 1;
330
+
331
+ // If no deadlines, sort by duration (longer tasks first)
332
+ return b.duration - a.duration;
333
+ });
334
+
335
+ try {
336
+ const now = dayjs();
337
+ const availableTimes: Record<keyof ActiveHours, dayjs.Dayjs> = {
338
+ work: getNextAvailableTime(activeHours.work, now),
339
+ personal: getNextAvailableTime(activeHours.personal, now),
340
+ meeting: getNextAvailableTime(activeHours.meeting, now),
341
+ };
342
+
343
+ let attempts = 0;
344
+ const maxAttempts = 2000;
345
+
346
+ while (taskPool.some((t) => t.remaining > 0) && attempts < maxAttempts) {
347
+ attempts++;
348
+ let anyScheduled = false;
349
+
350
+ for (const task of taskPool) {
351
+ if (task.remaining <= 0) continue;
352
+ const categoryHours =
353
+ activeHours[task.category as keyof ActiveHours] ?? activeHours.work;
354
+ if (!categoryHours || categoryHours.length === 0) {
355
+ logs.push({
356
+ type: 'error',
357
+ message: `No ${task.category} hours defined for task "${task.name}".`,
358
+ });
359
+ task.remaining = 0;
360
+ continue;
361
+ }
362
+
363
+ // Non-splittable: try to schedule as a single block
364
+ if (task.allowSplit === false) {
365
+ if (task.scheduledParts > 0) continue; // Already tried
366
+ let scheduled = false;
367
+ let tryTime =
368
+ availableTimes[task.category as keyof ActiveHours] ??
369
+ availableTimes.work;
370
+ let blockAttempts = 0;
371
+ let scheduledAfterDeadline = false;
372
+ while (!scheduled && blockAttempts < 50) {
373
+ blockAttempts++;
374
+ const availableSlots = getAvailableSlots(
375
+ tryTime,
376
+ categoryHours,
377
+ scheduledEvents,
378
+ minBuffer
379
+ );
380
+
381
+ const fitsTask = (s: DateRange) => {
382
+ const slotDuration = s.end.diff(s.start, 'hour', true);
383
+ const fitsTime = task.deadline
384
+ ? slotDuration >= task.duration &&
385
+ (s.end.isBefore(task.deadline) || s.end.isSame(task.deadline))
386
+ : slotDuration >= task.duration;
387
+
388
+ if (!fitsTime) return false;
389
+
390
+ // Smart Adaptive Windows: ensure it matches time preference if set
391
+ return matchesTimePreference(s.start, task.timePreference);
392
+ };
393
+
394
+ // Try to find a slot that fits, preferring peak hours for high-load tasks
395
+ let slot = availableSlots.find(
396
+ (s) =>
397
+ fitsTask(s) &&
398
+ (task.energyLoad !== 'high' ||
399
+ isPeakHour(s.start, settings?.energyProfile))
400
+ );
401
+
402
+ // If high load task couldn't find a peak slot, just find any slot that fits before deadline
403
+ if (!slot && task.energyLoad === 'high') {
404
+ slot = availableSlots.find(fitsTask);
405
+ }
406
+
407
+ // If still not found, allow after deadline
408
+ if (!slot) {
409
+ slot = availableSlots.find((s) => {
410
+ const slotDuration = s.end.diff(s.start, 'hour', true);
411
+ return (
412
+ slotDuration >= task.duration &&
413
+ matchesTimePreference(s.start, task.timePreference)
414
+ );
415
+ });
416
+ if (slot) scheduledAfterDeadline = true;
417
+ }
418
+
419
+ if (slot) {
420
+ const partStart = roundToQuarterHour(slot.start, false);
421
+ const partEnd = partStart.add(task.duration, 'hour');
422
+ const newEvent: Event = {
423
+ id: `${task.id}`,
424
+ name: task.name,
425
+ range: { start: partStart, end: partEnd },
426
+ locked: false,
427
+ taskId: task.id,
428
+ reason: calculateSchedulingReason(
429
+ task,
430
+ partStart,
431
+ settings?.energyProfile
432
+ ),
433
+ };
434
+ if (
435
+ (task.deadline && partEnd.isAfter(task.deadline)) ||
436
+ scheduledAfterDeadline
437
+ ) {
438
+ logs.push({
439
+ type: 'warning',
440
+ message: `Task "${task.name}" is scheduled past its deadline of ${task.deadline}.`,
441
+ });
442
+ }
443
+ scheduledEvents.push(newEvent);
444
+
445
+ availableTimes[task.category as keyof ActiveHours] =
446
+ roundToQuarterHour(partEnd.add(minBuffer, 'minute'), true) ??
447
+ availableTimes.work;
448
+ scheduled = true;
449
+ task.remaining = 0;
450
+ task.scheduledParts = 1;
451
+ anyScheduled = true;
452
+ } else {
453
+ tryTime = tryTime.add(1, 'day').startOf('day');
454
+ tryTime = getNextAvailableTime(categoryHours, tryTime);
455
+ logs.push({
456
+ type: 'warning',
457
+ message: `Moving task "${task.name}" to ${tryTime.format('YYYY-MM-DD')} due to lack of available time slots for non-splittable task.`,
458
+ });
459
+ }
460
+ }
461
+ if (!scheduled) {
462
+ logs.push({
463
+ type: 'error',
464
+ message: `Task "${task.name}" could not be scheduled as a single block after 50 attempts.`,
465
+ });
466
+ task.remaining = 0;
467
+ }
468
+ continue;
469
+ }
470
+
471
+ // Splittable: try to schedule the next part
472
+ let scheduledPart = false;
473
+ let tryTime =
474
+ availableTimes[task.category as keyof ActiveHours] ??
475
+ availableTimes.work;
476
+ let splitAttempts = 0;
477
+ while (task.remaining > 0 && splitAttempts < 50 && !scheduledPart) {
478
+ splitAttempts++;
479
+ const availableSlots = getAvailableSlots(
480
+ tryTime,
481
+ categoryHours,
482
+ scheduledEvents,
483
+ minBuffer
484
+ );
485
+
486
+ const fitsPart = (s: DateRange) => {
487
+ const slotDuration = s.end.diff(s.start, 'hour', true);
488
+ const fitsTime = task.deadline
489
+ ? s.end.isSame(task.deadline) || s.end.isBefore(task.deadline)
490
+ : slotDuration >= task.minDuration ||
491
+ slotDuration >= task.remaining;
492
+
493
+ if (!fitsTime) return false;
494
+
495
+ // Smart Adaptive Windows: ensure it matches time preference if set
496
+ return matchesTimePreference(s.start, task.timePreference);
497
+ };
498
+
499
+ // Try to find a slot that fits, preferring peak hours for high-load tasks
500
+ let slot = availableSlots.find(
501
+ (s) =>
502
+ fitsPart(s) &&
503
+ (task.energyLoad !== 'high' ||
504
+ isPeakHour(s.start, settings?.energyProfile))
505
+ );
506
+
507
+ // If high load task couldn't find a peak slot, just find any slot that fits before deadline
508
+ if (!slot && task.energyLoad === 'high') {
509
+ slot = availableSlots.find(fitsPart);
510
+ }
511
+
512
+ // If not found, allow after deadline
513
+ let scheduledAfterDeadline = false;
514
+ if (!slot) {
515
+ slot = availableSlots.find((s) =>
516
+ matchesTimePreference(s.start, task.timePreference)
517
+ );
518
+ if (slot && task.deadline && slot.end.isAfter(task.deadline)) {
519
+ scheduledAfterDeadline = true;
520
+ }
521
+ }
522
+
523
+ if (!slot) {
524
+ // Move to next day and try again
525
+ const nextTime = tryTime.add(1, 'day').startOf('day');
526
+ tryTime = getNextAvailableTime(categoryHours, nextTime);
527
+ continue;
528
+ }
529
+ const slotDuration = slot.end.diff(slot.start, 'hour', true);
530
+ let partDuration = Math.min(task.remaining, slotDuration);
531
+ partDuration = Math.min(partDuration, task.maxDuration);
532
+ partDuration = Math.max(
533
+ partDuration,
534
+ Math.min(task.minDuration, task.remaining)
535
+ );
536
+ partDuration = hoursToQuarterHours(partDuration);
537
+ if (
538
+ task.remaining < task.minDuration &&
539
+ partDuration < task.remaining
540
+ ) {
541
+ const extendedDuration = Math.min(task.minDuration, slotDuration);
542
+ if (extendedDuration >= task.minDuration) {
543
+ partDuration = hoursToQuarterHours(extendedDuration);
544
+ } else {
545
+ logs.push({
546
+ type: 'warning',
547
+ message: `Cannot schedule remaining ${task.remaining}h of task "${task.name}" due to minimum duration constraint (${task.minDuration}h).`,
548
+ });
549
+ task.remaining = 0;
550
+ break;
551
+ }
552
+ }
553
+ const partStart = roundToQuarterHour(slot.start, false);
554
+ const partEnd = partStart.add(partDuration, 'hour');
555
+ const totalParts = Math.ceil(task.duration / task.maxDuration);
556
+
557
+ const newEvent: Event = {
558
+ id: uuidv4(),
559
+ name:
560
+ totalParts > 1
561
+ ? `${task.name} (Part ${task.nextPart}/${totalParts})`
562
+ : task.name,
563
+ range: { start: partStart, end: partEnd },
564
+ taskId: task.id,
565
+ partNumber: totalParts > 1 ? task.nextPart : undefined,
566
+ totalParts: totalParts > 1 ? totalParts : undefined,
567
+ locked: false,
568
+ reason: calculateSchedulingReason(
569
+ task,
570
+ partStart,
571
+ settings?.energyProfile
572
+ ),
573
+ };
574
+ if (
575
+ (task.deadline && partEnd.isAfter(task.deadline)) ||
576
+ scheduledAfterDeadline
577
+ ) {
578
+ logs.push({
579
+ type: 'warning',
580
+ message: `Part ${task.nextPart} of task "${task.name}" is scheduled past its deadline of ${task.deadline}.`,
581
+ });
582
+ }
583
+ scheduledEvents.push(newEvent);
584
+ tryTime = roundToQuarterHour(partEnd.add(minBuffer, 'minute'), true);
585
+ availableTimes[task.category as keyof ActiveHours] = tryTime;
586
+ task.remaining -= partDuration;
587
+ task.scheduledParts++;
588
+ task.nextPart++;
589
+ anyScheduled = true;
590
+ scheduledPart = true;
591
+ }
592
+ }
593
+
594
+ // If no task could be scheduled in this round, break to avoid infinite loop
595
+ if (!anyScheduled) break;
596
+ }
597
+
598
+ // Log split info and unscheduled warnings
599
+ for (const task of taskPool) {
600
+ if (task.scheduledParts > 1) {
601
+ logs.push({
602
+ type: 'warning',
603
+ message: `Task "${task.name}" has been split into ${task.scheduledParts} parts due to duration constraints and available time slots.`,
604
+ });
605
+ }
606
+ if (task.remaining > 0) {
607
+ logs.push({
608
+ type: 'warning',
609
+ message: `Task "${task.name}" could not be fully scheduled. ${task.remaining}h remaining.`,
610
+ });
611
+ }
612
+ }
613
+
614
+ return { events: scheduledEvents, logs };
615
+ } catch (error) {
616
+ console.error('Error sorting task pool:', error);
617
+ }
618
+ // Initialize available times for each category - start from now
619
+ return {
620
+ events: [],
621
+ logs: [
622
+ {
623
+ type: 'error',
624
+ message: 'Scheduling failed due to an unexpected error.',
625
+ },
626
+ ],
627
+ };
628
+ };
629
+
630
+ function getNextAvailableTime(
631
+ hours: DateRange[],
632
+ startFrom: dayjs.Dayjs
633
+ ): dayjs.Dayjs {
634
+ if (!hours || hours.length === 0) {
635
+ return roundToQuarterHour(startFrom, true);
636
+ }
637
+
638
+ const firstHour = hours[0];
639
+ if (!firstHour) {
640
+ return roundToQuarterHour(startFrom, true);
641
+ }
642
+
643
+ // Check each day starting from startFrom
644
+ for (let day = 0; day < 30; day++) {
645
+ const checkDate = startFrom.add(day, 'day');
646
+
647
+ for (const hourRange of hours) {
648
+ const dayStart = hourRange.start
649
+ .year(checkDate.year())
650
+ .month(checkDate.month())
651
+ .date(checkDate.date());
652
+
653
+ const dayEnd = hourRange.end
654
+ .year(checkDate.year())
655
+ .month(checkDate.month())
656
+ .date(checkDate.date());
657
+
658
+ let effectiveStart: dayjs.Dayjs;
659
+
660
+ if (day === 0) {
661
+ // For the first day, start from the later of: startFrom or day start
662
+ effectiveStart = startFrom.isAfter(dayStart) ? startFrom : dayStart;
663
+ } else {
664
+ // For subsequent days, start from the beginning of active hours
665
+ effectiveStart = dayStart;
666
+ }
667
+
668
+ if (effectiveStart.isBefore(dayEnd)) {
669
+ return roundToQuarterHour(effectiveStart, true);
670
+ }
671
+ }
672
+ }
673
+
674
+ // Fallback to tomorrow if nothing found
675
+ return roundToQuarterHour(startFrom.add(1, 'day').startOf('day'), true);
676
+ }
677
+
678
+ function getAvailableSlots(
679
+ startTime: dayjs.Dayjs,
680
+ categoryHours: DateRange[],
681
+ existingEvents: Event[],
682
+ minBuffer: number = 0
683
+ ): DateRange[] {
684
+ const slots: DateRange[] = [];
685
+ const startDay = startTime.startOf('day');
686
+
687
+ // Optimization: Pre-filter events to a reasonable window (e.g., 14 days)
688
+ const windowEnd = startTime.add(14, 'day');
689
+ const relevantEvents = existingEvents.filter(
690
+ (e) => e.range.end.isAfter(startTime) && e.range.start.isBefore(windowEnd)
691
+ );
692
+
693
+ // Generate slots for the next 14 days (reduced from 30 for performance)
694
+ for (let day = 0; day < 14; day++) {
695
+ const checkDate = startDay.add(day, 'day');
696
+ const checkDateStart = checkDate.startOf('day');
697
+ const checkDateEnd = checkDate.endOf('day');
698
+
699
+ // Optimization: Filter events relevant to THIS day
700
+ const dayEvents = relevantEvents.filter(
701
+ (e) =>
702
+ e.range.start.isBefore(checkDateEnd) &&
703
+ e.range.end.isAfter(checkDateStart)
704
+ );
705
+
706
+ for (const hourRange of categoryHours) {
707
+ const dayStart = hourRange.start
708
+ .year(checkDate.year())
709
+ .month(checkDate.month())
710
+ .date(checkDate.date());
711
+ const dayEnd = hourRange.end
712
+ .year(checkDate.year())
713
+ .month(checkDate.month())
714
+ .date(checkDate.date());
715
+
716
+ let slotStart: dayjs.Dayjs;
717
+
718
+ if (day === 0) {
719
+ if (startTime.isSame(checkDate, 'day')) {
720
+ slotStart = startTime.isAfter(dayStart)
721
+ ? roundToQuarterHour(startTime, true)
722
+ : roundToQuarterHour(dayStart, true);
723
+ } else {
724
+ slotStart = roundToQuarterHour(dayStart, true);
725
+ }
726
+ } else {
727
+ slotStart = roundToQuarterHour(dayStart, true);
728
+ }
729
+
730
+ const slotEnd = roundToQuarterHour(dayEnd, false);
731
+
732
+ if (slotStart.isBefore(slotEnd)) {
733
+ // Use dayEvents which is already much smaller than existingEvents
734
+ const conflictingEvents = dayEvents
735
+ .filter(
736
+ (event) =>
737
+ event.range.start.isBefore(slotEnd) &&
738
+ event.range.end.isAfter(slotStart)
739
+ )
740
+ .sort((a, b) => a.range.start.diff(b.range.start));
741
+
742
+ if (conflictingEvents.length === 0) {
743
+ slots.push({ start: slotStart, end: slotEnd });
744
+ } else {
745
+ const firstConflict = conflictingEvents[0];
746
+ if (firstConflict) {
747
+ const preConflictEnd = roundToQuarterHour(
748
+ firstConflict.range.start.subtract(minBuffer, 'minute'),
749
+ false
750
+ );
751
+ if (slotStart.isBefore(preConflictEnd)) {
752
+ slots.push({ start: slotStart, end: preConflictEnd });
753
+ }
754
+ }
755
+
756
+ for (let i = 0; i < conflictingEvents.length; i++) {
757
+ const currentEvent = conflictingEvents[i];
758
+ const nextEvent = conflictingEvents[i + 1];
759
+
760
+ if (!currentEvent) continue;
761
+
762
+ const currentEventEndWithBuffer = roundToQuarterHour(
763
+ currentEvent.range.end.add(minBuffer, 'minute'),
764
+ true
765
+ );
766
+ const nextEventStartWithBuffer = nextEvent
767
+ ? roundToQuarterHour(
768
+ nextEvent.range.start.subtract(minBuffer, 'minute'),
769
+ false
770
+ )
771
+ : slotEnd;
772
+
773
+ if (currentEventEndWithBuffer.isBefore(nextEventStartWithBuffer)) {
774
+ slots.push({
775
+ start: currentEventEndWithBuffer,
776
+ end: nextEventStartWithBuffer,
777
+ });
778
+ }
779
+ }
780
+ }
781
+ }
782
+ }
783
+
784
+ // Optimization: If we already found enough slots for a few days, stop early
785
+ if (slots.length >= 20) break;
786
+ }
787
+
788
+ return slots
789
+ .filter((slot) => slot.end.diff(slot.start, 'minute') >= 15)
790
+ .sort((a, b) => a.start.diff(b.start));
791
+ }