@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.
- package/README.md +76 -0
- package/package.json +106 -0
- package/src/api-key-hash.ts +28 -0
- package/src/calendar/events.ts +34 -0
- package/src/calendar/route.ts +114 -0
- package/src/chat/credit-source.ts +1 -0
- package/src/chat/google/chat-request-schema.ts +150 -0
- package/src/chat/google/default-system-instruction.ts +198 -0
- package/src/chat/google/message-file-processing.ts +212 -0
- package/src/chat/google/mira-step-preparation.ts +221 -0
- package/src/chat/google/new/route.ts +368 -0
- package/src/chat/google/route-auth.ts +81 -0
- package/src/chat/google/route-chat-resolution.ts +98 -0
- package/src/chat/google/route-credits.ts +61 -0
- package/src/chat/google/route-message-preparation.ts +331 -0
- package/src/chat/google/route-mira-runtime.ts +206 -0
- package/src/chat/google/route.ts +632 -0
- package/src/chat/google/stream-finish-persistence.ts +722 -0
- package/src/chat/google/summary/route.ts +153 -0
- package/src/chat/mira-render-ui-policy.ts +540 -0
- package/src/chat/mira-system-instruction.ts +484 -0
- package/src/chat-sdk/adapters.ts +389 -0
- package/src/chat-sdk/registry.ts +197 -0
- package/src/chat-sdk.ts +33 -0
- package/src/core.ts +3 -0
- package/src/credits/cap-output-tokens.ts +90 -0
- package/src/credits/check-credits.ts +232 -0
- package/src/credits/constants.ts +30 -0
- package/src/credits/index.ts +46 -0
- package/src/credits/model-mapping.ts +92 -0
- package/src/credits/reservations.ts +514 -0
- package/src/credits/resolve-plan-model.ts +219 -0
- package/src/credits/sync-gateway-models.ts +351 -0
- package/src/credits/types.ts +109 -0
- package/src/credits/use-ai-credits.ts +3 -0
- package/src/embeddings/metered.ts +283 -0
- package/src/executions/route.ts +137 -0
- package/src/generate/route.ts +411 -0
- package/src/hooks.ts +7 -0
- package/src/meetings/summary/route.ts +7 -0
- package/src/meetings/transcription/route.ts +134 -0
- package/src/memory/client.ts +158 -0
- package/src/memory/config.ts +38 -0
- package/src/memory/index.ts +32 -0
- package/src/memory/ingest.ts +51 -0
- package/src/memory/middleware.ts +35 -0
- package/src/memory/operations.ts +480 -0
- package/src/memory/scope.ts +102 -0
- package/src/memory/settings.ts +121 -0
- package/src/memory/types.ts +101 -0
- package/src/memory/workspace.ts +36 -0
- package/src/memory.ts +1 -0
- package/src/mind/patch.ts +146 -0
- package/src/mind/route.ts +687 -0
- package/src/mind/tools.ts +1500 -0
- package/src/mind/types.ts +20 -0
- package/src/object/core.ts +3 -0
- package/src/object/flashcards/route.ts +140 -0
- package/src/object/quizzes/explanation/route.ts +145 -0
- package/src/object/quizzes/route.ts +142 -0
- package/src/object/types.ts +187 -0
- package/src/object/year-plan/route.ts +196 -0
- package/src/react.ts +1 -0
- package/src/scheduling/algorithm.ts +791 -0
- package/src/scheduling/default.ts +36 -0
- package/src/scheduling/duration-optimizer.ts +689 -0
- package/src/scheduling/index.ts +79 -0
- package/src/scheduling/priority-calculator.ts +187 -0
- package/src/scheduling/recurrence-calculator.ts +621 -0
- package/src/scheduling/templates.ts +892 -0
- package/src/scheduling/types.ts +136 -0
- package/src/scheduling/web-adapter.ts +308 -0
- package/src/scheduling.ts +6 -0
- package/src/supported-actions.ts +1 -0
- package/src/supported-providers.ts +6 -0
- package/src/tools/context-builder.ts +372 -0
- package/src/tools/core.ts +1 -0
- package/src/tools/definitions/calendar.ts +106 -0
- package/src/tools/definitions/finance.ts +197 -0
- package/src/tools/definitions/image.ts +74 -0
- package/src/tools/definitions/memory.ts +83 -0
- package/src/tools/definitions/meta.ts +154 -0
- package/src/tools/definitions/render-ui.ts +81 -0
- package/src/tools/definitions/tasks.ts +343 -0
- package/src/tools/definitions/time-tracking.ts +381 -0
- package/src/tools/definitions/workspace-context.ts +45 -0
- package/src/tools/definitions/workspace-user-chat.ts +111 -0
- package/src/tools/executors/calendar.ts +371 -0
- package/src/tools/executors/chat.ts +15 -0
- package/src/tools/executors/finance.ts +638 -0
- package/src/tools/executors/helpers/encryption.ts +107 -0
- package/src/tools/executors/image.ts +247 -0
- package/src/tools/executors/markitdown.ts +684 -0
- package/src/tools/executors/memory.ts +277 -0
- package/src/tools/executors/parallel-checks.ts +176 -0
- package/src/tools/executors/qr.ts +170 -0
- package/src/tools/executors/scope-helpers.ts +192 -0
- package/src/tools/executors/search.ts +149 -0
- package/src/tools/executors/settings.ts +40 -0
- package/src/tools/executors/tasks.ts +1087 -0
- package/src/tools/executors/theme.ts +23 -0
- package/src/tools/executors/timer/timer-categories-executor.ts +110 -0
- package/src/tools/executors/timer/timer-category-mutations.ts +240 -0
- package/src/tools/executors/timer/timer-goal-mutations.ts +323 -0
- package/src/tools/executors/timer/timer-goals-executor.ts +272 -0
- package/src/tools/executors/timer/timer-helpers.ts +372 -0
- package/src/tools/executors/timer/timer-mutation-schemas.ts +160 -0
- package/src/tools/executors/timer/timer-mutation-types.ts +212 -0
- package/src/tools/executors/timer/timer-mutations.ts +19 -0
- package/src/tools/executors/timer/timer-queries.ts +18 -0
- package/src/tools/executors/timer/timer-session-lifecycle.ts +299 -0
- package/src/tools/executors/timer/timer-session-mutations.ts +10 -0
- package/src/tools/executors/timer/timer-session-queries.ts +153 -0
- package/src/tools/executors/timer/timer-session-updates.ts +200 -0
- package/src/tools/executors/timer/timer-sessions-executor.ts +91 -0
- package/src/tools/executors/timer/timer-stats-executor.ts +157 -0
- package/src/tools/executors/timer.ts +22 -0
- package/src/tools/executors/user.ts +60 -0
- package/src/tools/executors/workspace.ts +135 -0
- package/src/tools/json-render-catalog.ts +875 -0
- package/src/tools/mira-tool-definitions.ts +55 -0
- package/src/tools/mira-tool-dispatcher.ts +265 -0
- package/src/tools/mira-tool-metadata.ts +164 -0
- package/src/tools/mira-tool-names.ts +95 -0
- package/src/tools/mira-tool-render-ui.ts +54 -0
- package/src/tools/mira-tool-types.ts +17 -0
- package/src/tools/mira-tools.ts +167 -0
- package/src/tools/normalize-render-ui-input.ts +321 -0
- package/src/tools/workspace-context.ts +233 -0
- 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
|
+
}
|