@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,689 @@
1
+ /**
2
+ * Duration Optimizer for Habit Scheduling
3
+ *
4
+ * This module provides smart duration optimization for habits.
5
+ * It determines the optimal duration within the min/max range based on:
6
+ * - Time slot characteristics (ideal time match, preference match)
7
+ * - Available slot size
8
+ * - Habit preferences
9
+ *
10
+ * Strategy:
11
+ * - Maximize duration in ideal time slots (get most value from habit)
12
+ * - Use preferred duration in preference-matching slots
13
+ * - Shrink to minimum viable in constrained slots
14
+ */
15
+
16
+ import type { TimeOfDayPreference } from '@tuturuuu/types/primitives/Habit';
17
+ import type { TaskPriority } from '@tuturuuu/types/primitives/Priority';
18
+ import type { SchedulingWeights } from './types';
19
+
20
+ // ============================================================================
21
+ // TIMEZONE HELPERS
22
+ // ============================================================================
23
+
24
+ /**
25
+ * Get the local hour for a Date in a specific timezone
26
+ * Falls back to system local time if timezone is not provided or invalid
27
+ */
28
+ export function getLocalHour(
29
+ date: Date,
30
+ timezone: string | null | undefined
31
+ ): number {
32
+ if (!timezone || timezone === 'auto') {
33
+ return date.getHours(); // Use system local time
34
+ }
35
+
36
+ try {
37
+ const formatter = new Intl.DateTimeFormat('en-US', {
38
+ timeZone: timezone,
39
+ hour: 'numeric',
40
+ hour12: false,
41
+ });
42
+ const parts = formatter.formatToParts(date);
43
+ const hourPart = parts.find((p) => p.type === 'hour');
44
+ return parseInt(hourPart?.value || '0', 10);
45
+ } catch {
46
+ return date.getHours(); // Fallback to system local time
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Get the local minute for a Date in a specific timezone
52
+ * Falls back to system local time if timezone is not provided or invalid
53
+ */
54
+ export function getLocalMinute(
55
+ date: Date,
56
+ timezone: string | null | undefined
57
+ ): number {
58
+ if (!timezone || timezone === 'auto') {
59
+ return date.getMinutes(); // Use system local time
60
+ }
61
+
62
+ try {
63
+ const formatter = new Intl.DateTimeFormat('en-US', {
64
+ timeZone: timezone,
65
+ minute: 'numeric',
66
+ });
67
+ const parts = formatter.formatToParts(date);
68
+ const minutePart = parts.find((p) => p.type === 'minute');
69
+ return parseInt(minutePart?.value || '0', 10);
70
+ } catch {
71
+ return date.getMinutes(); // Fallback to system local time
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Create a Date object from a time string (HH:MM) in a specific timezone
77
+ * on a given day
78
+ */
79
+ export function createDateInTimezone(
80
+ baseDate: Date,
81
+ hours: number,
82
+ minutes: number,
83
+ timezone: string | null | undefined
84
+ ): Date {
85
+ // Get the date components in the target timezone
86
+ const year = baseDate.getFullYear();
87
+ const month = baseDate.getMonth();
88
+ const day = baseDate.getDate();
89
+
90
+ // Create a date string in ISO format and let the Date constructor parse it
91
+ // This works because we want the resulting Date to represent the moment when
92
+ // it's hours:minutes in the target timezone
93
+
94
+ if (!timezone || timezone === 'auto') {
95
+ // Use system local time
96
+ const result = new Date(year, month, day, hours, minutes, 0, 0);
97
+ return result;
98
+ }
99
+
100
+ try {
101
+ // Create a date at the specified local time in the target timezone
102
+ // We do this by creating a formatter and working backwards
103
+ const testDate = new Date(year, month, day, 12, 0, 0, 0);
104
+
105
+ // Get the UTC offset for the target timezone at this date
106
+ const formatter = new Intl.DateTimeFormat('en-US', {
107
+ timeZone: timezone,
108
+ hour: 'numeric',
109
+ minute: 'numeric',
110
+ hour12: false,
111
+ });
112
+
113
+ // Format in target timezone and parse
114
+ const targetStr = formatter.format(testDate);
115
+ const [targetHour] = targetStr.split(':').map(Number);
116
+ const systemHour = testDate.getHours();
117
+
118
+ // Calculate offset (this is an approximation but works for most cases)
119
+ const offsetHours = (systemHour - (targetHour ?? systemHour)) % 24;
120
+
121
+ const result = new Date(
122
+ year,
123
+ month,
124
+ day,
125
+ hours + offsetHours,
126
+ minutes,
127
+ 0,
128
+ 0
129
+ );
130
+ return result;
131
+ } catch {
132
+ // Fallback to system local time
133
+ return new Date(year, month, day, hours, minutes, 0, 0);
134
+ }
135
+ }
136
+
137
+ // ============================================================================
138
+ // TYPES
139
+ // ============================================================================
140
+
141
+ /**
142
+ * Habit duration configuration
143
+ */
144
+ export interface HabitDurationConfig {
145
+ /** Preferred duration in minutes */
146
+ duration_minutes: number;
147
+ /** Minimum acceptable duration in minutes (optional) */
148
+ min_duration_minutes?: number | null;
149
+ /** Maximum beneficial duration in minutes (optional) */
150
+ max_duration_minutes?: number | null;
151
+ /** Specific preferred time (HH:MM format) */
152
+ ideal_time?: string | null;
153
+ /** Time-of-day preference */
154
+ time_preference?: TimeOfDayPreference | null;
155
+ }
156
+
157
+ /**
158
+ * Time slot information
159
+ */
160
+ export interface TimeSlotInfo {
161
+ /** Slot start time */
162
+ start: Date;
163
+ /** Slot end time */
164
+ end: Date;
165
+ /** Maximum available minutes in this slot (accounting for existing events) */
166
+ maxAvailable: number;
167
+ }
168
+
169
+ /**
170
+ * Slot characteristics for optimization decisions
171
+ */
172
+ export interface SlotCharacteristics {
173
+ /** Whether this slot matches the habit's ideal_time exactly */
174
+ matchesIdealTime: boolean;
175
+ /** Whether this slot falls within the habit's time_preference range */
176
+ matchesPreference: boolean;
177
+ }
178
+
179
+ /**
180
+ * Get effective duration bounds for a habit
181
+ * Fills in defaults if min/max are not set
182
+ */
183
+ export function getEffectiveDurationBounds(habit: HabitDurationConfig): {
184
+ preferred: number;
185
+ min: number;
186
+ max: number;
187
+ } {
188
+ const preferred = habit.duration_minutes;
189
+
190
+ // Default min: 50% of preferred (minimum 15 minutes)
191
+ const min =
192
+ habit.min_duration_minutes ?? Math.max(15, Math.floor(preferred * 0.5));
193
+
194
+ // Default max: 150% of preferred (maximum 180 minutes)
195
+ const max =
196
+ habit.max_duration_minutes ?? Math.min(180, Math.ceil(preferred * 1.5));
197
+
198
+ return { preferred, min, max };
199
+ }
200
+
201
+ /**
202
+ * Calculate the optimal duration for a habit in a given slot
203
+ *
204
+ * Strategy:
205
+ * 1. If slot matches ideal_time exactly - maximize (use max duration)
206
+ * 2. If slot matches time_preference - use preferred duration
207
+ * 3. If slot is constrained - use minimum viable duration
208
+ * 4. Otherwise - use preferred duration
209
+ */
210
+ export function calculateOptimalDuration(
211
+ habit: HabitDurationConfig,
212
+ slot: TimeSlotInfo,
213
+ characteristics: SlotCharacteristics
214
+ ): number {
215
+ const { preferred, min, max } = getEffectiveDurationBounds(habit);
216
+
217
+ // If slot can't fit even minimum duration, return 0 (can't schedule)
218
+ if (slot.maxAvailable < min) {
219
+ return 0;
220
+ }
221
+
222
+ // If slot matches ideal time exactly, maximize the duration
223
+ // This gives the user maximum benefit from their preferred time
224
+ if (characteristics.matchesIdealTime) {
225
+ return Math.min(max, slot.maxAvailable);
226
+ }
227
+
228
+ // If slot matches time preference, use preferred duration
229
+ if (characteristics.matchesPreference) {
230
+ return Math.min(preferred, slot.maxAvailable);
231
+ }
232
+
233
+ // If slot is constrained (can't fit preferred), use minimum viable
234
+ if (slot.maxAvailable < preferred) {
235
+ return Math.max(min, slot.maxAvailable);
236
+ }
237
+
238
+ // Default: use preferred duration
239
+ return Math.min(preferred, slot.maxAvailable);
240
+ }
241
+
242
+ /**
243
+ * Check if a time (HH:MM) falls within a time slot
244
+ * The idealTime is in the user's timezone (e.g., "18:30" means 6:30 PM local)
245
+ */
246
+ export function timeMatchesSlot(
247
+ idealTime: string,
248
+ slot: TimeSlotInfo,
249
+ timezone?: string | null
250
+ ): boolean {
251
+ const [hours, minutes] = idealTime.split(':').map(Number);
252
+ if (hours === undefined || minutes === undefined) return false;
253
+
254
+ // Create the ideal date in the target timezone
255
+ const idealDate = createDateInTimezone(slot.start, hours, minutes, timezone);
256
+
257
+ // Check if ideal time falls within the slot
258
+ return idealDate >= slot.start && idealDate < slot.end;
259
+ }
260
+
261
+ /**
262
+ * Time ranges for time-of-day preferences
263
+ */
264
+ const TIME_PREFERENCE_RANGES: Record<
265
+ TimeOfDayPreference,
266
+ { start: number; end: number }
267
+ > = {
268
+ morning: { start: 6, end: 12 }, // 6am-12pm
269
+ afternoon: { start: 12, end: 17 }, // 12pm-5pm
270
+ evening: { start: 17, end: 21 }, // 5pm-9pm
271
+ night: { start: 21, end: 24 }, // 9pm-12am
272
+ };
273
+
274
+ /**
275
+ * Check if a slot falls within a time-of-day preference
276
+ * Uses timezone-aware hour extraction
277
+ */
278
+ export function slotMatchesPreference(
279
+ preference: TimeOfDayPreference,
280
+ slot: TimeSlotInfo,
281
+ timezone?: string | null
282
+ ): boolean {
283
+ const range = TIME_PREFERENCE_RANGES[preference];
284
+ if (!range) return false;
285
+
286
+ // Use timezone-aware hours
287
+ const slotStartHour = getLocalHour(slot.start, timezone);
288
+ const slotEndHour = getLocalHour(slot.end, timezone);
289
+
290
+ // Slot matches if it overlaps with the preference range
291
+ return slotStartHour < range.end && slotEndHour > range.start;
292
+ }
293
+
294
+ /**
295
+ * Get slot characteristics for a habit
296
+ */
297
+ export function getSlotCharacteristics(
298
+ habit: HabitDurationConfig,
299
+ slot: TimeSlotInfo,
300
+ timezone?: string | null
301
+ ): SlotCharacteristics {
302
+ const matchesIdealTime = habit.ideal_time
303
+ ? timeMatchesSlot(habit.ideal_time, slot, timezone)
304
+ : false;
305
+
306
+ const matchesPreference = habit.time_preference
307
+ ? slotMatchesPreference(habit.time_preference, slot, timezone)
308
+ : false;
309
+
310
+ return { matchesIdealTime, matchesPreference };
311
+ }
312
+
313
+ /**
314
+ * Score a slot for a habit (higher = better fit)
315
+ * Used when choosing between multiple available slots
316
+ */
317
+ export function scoreSlotForHabit(
318
+ habit: HabitDurationConfig,
319
+ slot: TimeSlotInfo,
320
+ timezone?: string | null,
321
+ weights?: SchedulingWeights
322
+ ): number {
323
+ const characteristics = getSlotCharacteristics(habit, slot, timezone);
324
+ let score = 0;
325
+
326
+ // Ideal time match is the best
327
+ if (characteristics.matchesIdealTime) {
328
+ score += weights?.habitIdealTimeBonus ?? 1000;
329
+ }
330
+
331
+ // Time preference match is second best
332
+ if (characteristics.matchesPreference) {
333
+ score += weights?.habitPreferenceBonus ?? 500;
334
+ }
335
+
336
+ // Prefer slots that can fit preferred duration
337
+ const { preferred, min } = getEffectiveDurationBounds(habit);
338
+ if (slot.maxAvailable >= preferred) {
339
+ score += 200;
340
+ } else if (slot.maxAvailable >= min) {
341
+ score += 100;
342
+ }
343
+
344
+ // Apply time-based scoring using timezone-aware hours
345
+ const slotHour = getLocalHour(slot.start, timezone);
346
+ if (habit.time_preference || habit.ideal_time) {
347
+ // When user has set a preference, slightly prefer earlier slots as tiebreaker
348
+ score -= slotHour * 0.1;
349
+ } else {
350
+ // When no preference set, prefer middle-of-day (closer to noon)
351
+ // This prevents habits from always stacking at 7am
352
+ const distanceFromNoon = Math.abs(slotHour - 12);
353
+ score -= distanceFromNoon * 0.5;
354
+ }
355
+
356
+ return score;
357
+ }
358
+
359
+ /**
360
+ * Find the best slot for a habit from a list of available slots
361
+ */
362
+ export function findBestSlotForHabit(
363
+ habit: HabitDurationConfig,
364
+ slots: TimeSlotInfo[],
365
+ timezone?: string | null,
366
+ weights?: SchedulingWeights
367
+ ): TimeSlotInfo | null {
368
+ const { min } = getEffectiveDurationBounds(habit);
369
+
370
+ // Filter to slots that can fit at least minimum duration
371
+ const viableSlots = slots.filter((slot) => slot.maxAvailable >= min);
372
+
373
+ if (viableSlots.length === 0) {
374
+ return null;
375
+ }
376
+
377
+ // Score each slot and return the best one
378
+ let bestSlot = viableSlots[0]!;
379
+ let bestScore = scoreSlotForHabit(habit, bestSlot, timezone, weights);
380
+
381
+ for (let i = 1; i < viableSlots.length; i++) {
382
+ const slot = viableSlots[i]!;
383
+ const score = scoreSlotForHabit(habit, slot, timezone, weights);
384
+ if (score > bestScore) {
385
+ bestScore = score;
386
+ bestSlot = slot;
387
+ }
388
+ }
389
+
390
+ return bestSlot;
391
+ }
392
+
393
+ // ============================================================================
394
+ // IDEAL START TIME CALCULATION
395
+ // ============================================================================
396
+
397
+ /**
398
+ * Round a time to the next 15-minute boundary
399
+ * e.g., 2:11 -> 2:15, 2:00 -> 2:00, 2:16 -> 2:30
400
+ */
401
+ export function roundToNext15Minutes(date: Date): Date {
402
+ const result = new Date(date);
403
+ const minutes = result.getMinutes();
404
+ const remainder = minutes % 15;
405
+
406
+ if (remainder === 0) {
407
+ // Already on a 15-minute boundary
408
+ result.setSeconds(0, 0);
409
+ return result;
410
+ }
411
+
412
+ // Round up to next 15-minute mark
413
+ result.setMinutes(minutes + (15 - remainder), 0, 0);
414
+ return result;
415
+ }
416
+
417
+ /**
418
+ * Calculate the ideal start time within a slot for a habit
419
+ *
420
+ * When a slot is large (e.g., 7am-6pm), we don't want to always start at slot.start.
421
+ * Instead, we calculate the best start time based on the habit's preferences:
422
+ * - If ideal_time is set and falls within slot, use it
423
+ * - If time_preference is set, find the middle of the preference range within slot
424
+ * - Otherwise, aim for noon (middle of day) to distribute events evenly
425
+ *
426
+ * All times are rounded to 15-minute boundaries.
427
+ * If `now` is provided, ensures start time is not before now.
428
+ *
429
+ * IMPORTANT: The ideal_time and time_preference are in the USER'S timezone.
430
+ * The timezone parameter ensures we correctly interpret these times.
431
+ */
432
+ export function calculateIdealStartTimeForHabit(
433
+ habit: HabitDurationConfig,
434
+ slot: TimeSlotInfo,
435
+ duration: number,
436
+ now?: Date,
437
+ timezone?: string | null
438
+ ): Date {
439
+ // Effective slot start (must be >= now if provided)
440
+ let effectiveSlotStart = new Date(slot.start);
441
+ if (now && now > slot.start) {
442
+ effectiveSlotStart = roundToNext15Minutes(now);
443
+ }
444
+
445
+ // Latest possible start time to fit the duration
446
+ const latestStart = new Date(slot.end.getTime() - duration * 60000);
447
+
448
+ // If the slot can barely fit the duration, just use effective slot start
449
+ if (latestStart <= effectiveSlotStart) {
450
+ return roundToNext15Minutes(effectiveSlotStart);
451
+ }
452
+
453
+ // 1. If habit has ideal_time and it falls within the slot, use it
454
+ // ideal_time is in the user's timezone (e.g., "18:30" means 6:30 PM local)
455
+ if (habit.ideal_time) {
456
+ const [hours, minutes] = habit.ideal_time.split(':').map(Number);
457
+ if (hours !== undefined && minutes !== undefined) {
458
+ // Create the ideal start time in the TARGET timezone
459
+ const idealStart = createDateInTimezone(
460
+ slot.start,
461
+ hours,
462
+ minutes,
463
+ timezone
464
+ );
465
+ const roundedIdeal = roundToNext15Minutes(idealStart);
466
+
467
+ // Check if ideal time falls within usable range [effectiveSlotStart, latestStart]
468
+ if (roundedIdeal >= effectiveSlotStart && roundedIdeal <= latestStart) {
469
+ return roundedIdeal;
470
+ }
471
+ }
472
+ }
473
+
474
+ // 2. If habit has time_preference, aim for the middle of that preference range
475
+ if (habit.time_preference) {
476
+ const preferenceRanges: Record<
477
+ TimeOfDayPreference,
478
+ { start: number; end: number; ideal: number }
479
+ > = {
480
+ morning: { start: 6, end: 12, ideal: 9 }, // Ideal morning: 9am
481
+ afternoon: { start: 12, end: 17, ideal: 14 }, // Ideal afternoon: 2pm
482
+ evening: { start: 17, end: 21, ideal: 18 }, // Ideal evening: 6pm
483
+ night: { start: 21, end: 24, ideal: 22 }, // Ideal night: 10pm
484
+ };
485
+
486
+ const range = preferenceRanges[habit.time_preference];
487
+ if (range) {
488
+ // Try the ideal time for this preference (in target timezone)
489
+ const idealStart = createDateInTimezone(
490
+ slot.start,
491
+ range.ideal,
492
+ 0,
493
+ timezone
494
+ );
495
+
496
+ if (idealStart >= effectiveSlotStart && idealStart <= latestStart) {
497
+ return idealStart; // Already on hour boundary
498
+ }
499
+
500
+ // If ideal is outside slot, try the start of preference range
501
+ const rangeStart = createDateInTimezone(
502
+ slot.start,
503
+ range.start,
504
+ 0,
505
+ timezone
506
+ );
507
+
508
+ if (rangeStart >= effectiveSlotStart && rangeStart <= latestStart) {
509
+ return rangeStart;
510
+ }
511
+ }
512
+ }
513
+
514
+ // 3. Default: aim for noon (12pm local time) to distribute events in middle of day
515
+ const noonTarget = createDateInTimezone(slot.start, 12, 0, timezone);
516
+
517
+ // If noon is within the usable range, use it
518
+ if (noonTarget >= effectiveSlotStart && noonTarget <= latestStart) {
519
+ return noonTarget;
520
+ }
521
+
522
+ // If noon is before effective slot start, use effective slot start
523
+ if (noonTarget < effectiveSlotStart) {
524
+ return roundToNext15Minutes(effectiveSlotStart);
525
+ }
526
+
527
+ // If noon is after latest start, use latest start (closest to noon we can get)
528
+ return roundToNext15Minutes(latestStart);
529
+ }
530
+
531
+ /**
532
+ * Calculate the ideal start time within a slot for a task
533
+ *
534
+ * Tasks should start as soon as possible (ASAP) to maximize productivity.
535
+ * All times are rounded to 15-minute boundaries.
536
+ * Ensures start time is not before `now`.
537
+ */
538
+ export function calculateIdealStartTimeForTask(
539
+ _task: TaskSlotConfig,
540
+ slot: TimeSlotInfo,
541
+ duration: number,
542
+ now: Date
543
+ ): Date {
544
+ // Effective slot start (must be >= now, rounded to next 15-min)
545
+ let effectiveSlotStart = new Date(slot.start);
546
+ if (now > slot.start) {
547
+ effectiveSlotStart = roundToNext15Minutes(now);
548
+ } else {
549
+ effectiveSlotStart = roundToNext15Minutes(slot.start);
550
+ }
551
+
552
+ // Latest possible start time to fit the duration
553
+ const latestStart = new Date(slot.end.getTime() - duration * 60000);
554
+
555
+ // If the slot can barely fit the duration, use effective slot start
556
+ if (latestStart <= effectiveSlotStart) {
557
+ return effectiveSlotStart;
558
+ }
559
+
560
+ // Tasks should start ASAP - use the earliest available time in the slot
561
+ // (already rounded to 15-minute boundary)
562
+ return effectiveSlotStart;
563
+ }
564
+
565
+ // ============================================================================
566
+ // TASK SLOT SCORING
567
+ // ============================================================================
568
+
569
+ /**
570
+ * Task slot configuration for scoring
571
+ */
572
+ export interface TaskSlotConfig {
573
+ /** Task deadline (null = no deadline) */
574
+ deadline?: Date | null;
575
+ /** Task priority */
576
+ priority: TaskPriority;
577
+ /** Optional preferred time of day */
578
+ preferredTimeOfDay?: TimeOfDayPreference | null;
579
+ }
580
+
581
+ /**
582
+ * Score a slot for a task (higher = better fit)
583
+ *
584
+ * Scoring strategy:
585
+ * Tasks should be scheduled ASAP to maximize time efficiency.
586
+ * Earlier slots always score higher to ensure gaps are filled before
587
+ * moving to later time blocks.
588
+ *
589
+ * 1. Time-based: Prefer EARLIEST slots (+300 at 7am, decreasing by hour)
590
+ * 2. Slot size: Small bonus for adequate size (max 50 points)
591
+ * 3. Time preference: If task has preference, respect it (+500)
592
+ * 4. Priority bonus: Higher priority tasks get bonus
593
+ */
594
+ export function scoreSlotForTask(
595
+ task: TaskSlotConfig,
596
+ slot: TimeSlotInfo,
597
+ now: Date,
598
+ timezone?: string | null,
599
+ weights?: SchedulingWeights
600
+ ): number {
601
+ let score = 0;
602
+
603
+ // Use timezone-aware hour for scoring
604
+ const slotHour = getLocalHour(slot.start, timezone);
605
+
606
+ // 1. ALWAYS prefer earlier slots - tasks should fill gaps ASAP
607
+ // Earlier hour = higher score (+300 at midnight, -2 per hour)
608
+ // This ensures gaps are filled before moving to later slots
609
+ // Reduced penalty from -10 to -2 to prevent gaps between tasks
610
+ score += (weights?.taskBaseEarlyBonus ?? 300) - slotHour * 2;
611
+
612
+ // 2. Small bonus for slots that fit well (max 50 points)
613
+ // This is secondary to time preference - we want earlier slots first
614
+ score += (Math.min(slot.maxAvailable, 120) / 120) * 50;
615
+
616
+ // 3. If task has time preference, respect it (+500)
617
+ if (task.preferredTimeOfDay) {
618
+ if (slotMatchesPreference(task.preferredTimeOfDay, slot, timezone)) {
619
+ score += weights?.taskPreferenceBonus ?? 500;
620
+ }
621
+ }
622
+
623
+ // 4. Deadline urgency bonus for even earlier scheduling
624
+ if (task.deadline) {
625
+ const hoursUntilDeadline =
626
+ (task.deadline.getTime() - now.getTime()) / (1000 * 60 * 60);
627
+
628
+ if (hoursUntilDeadline < 24) {
629
+ // URGENT: Extra bonus for earliest slots
630
+ score += 200 - slotHour * 5;
631
+ } else if (hoursUntilDeadline < 72) {
632
+ // SOON: Smaller bonus for earlier
633
+ score += 100 - slotHour * 2;
634
+ }
635
+ // Not urgent: No extra bonus, rely on base time scoring
636
+ }
637
+
638
+ // 5. Priority bonus for better slot placement
639
+ const priorityBonus: Record<TaskPriority, number> = {
640
+ critical: 200,
641
+ high: 100,
642
+ normal: 0,
643
+ low: -50,
644
+ };
645
+ score += priorityBonus[task.priority] ?? 0;
646
+
647
+ return score;
648
+ }
649
+
650
+ /**
651
+ * Find the best slot for a task from a list of available slots
652
+ *
653
+ * @param task - Task configuration
654
+ * @param slots - Available time slots
655
+ * @param minDuration - Minimum required duration in minutes
656
+ * @param now - Current time (for deadline calculations)
657
+ * @param timezone - Target timezone for scoring
658
+ * @returns Best slot or null if no viable slots
659
+ */
660
+ export function findBestSlotForTask(
661
+ task: TaskSlotConfig,
662
+ slots: TimeSlotInfo[],
663
+ minDuration: number,
664
+ now: Date,
665
+ timezone?: string | null,
666
+ weights?: SchedulingWeights
667
+ ): TimeSlotInfo | null {
668
+ // Filter to slots that can fit at least minimum duration
669
+ const viableSlots = slots.filter((slot) => slot.maxAvailable >= minDuration);
670
+
671
+ if (viableSlots.length === 0) {
672
+ return null;
673
+ }
674
+
675
+ // Score each slot and return the best one
676
+ let bestSlot = viableSlots[0]!;
677
+ let bestScore = scoreSlotForTask(task, bestSlot, now, timezone, weights);
678
+
679
+ for (let i = 1; i < viableSlots.length; i++) {
680
+ const slot = viableSlots[i]!;
681
+ const score = scoreSlotForTask(task, slot, now, timezone, weights);
682
+ if (score > bestScore) {
683
+ bestScore = score;
684
+ bestSlot = slot;
685
+ }
686
+ }
687
+
688
+ return bestSlot;
689
+ }