@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,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
|
+
}
|