import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Not, IsNull } from 'typeorm'; import { addMinutes, startOfDay, addDays, setHours, setMinutes, isAfter, isBefore, areIntervalsOverlapping, getDay, } from 'date-fns'; import { Task } from '../tasks/entities/task.entity'; import { CalendarEvent } from '../calendar/entities/calendar-event.entity'; import { User } from '../users/entities/user.entity'; import { TaskStatus, type WorkingHours } from '@nick-tracker/shared-types'; interface TimeSlot { start: Date; end: Date; } const DEFAULT_WORKING_HOURS: WorkingHours = { monday: { start: '09:00', end: '17:00' }, tuesday: { start: '09:00', end: '17:00' }, wednesday: { start: '09:00', end: '17:00' }, thursday: { start: '09:00', end: '17:00' }, friday: { start: '09:00', end: '17:00' }, }; const DAY_NAMES = [ 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', ] as const; @Injectable() export class ScheduleService { private readonly logger = new Logger(ScheduleService.name); constructor( @InjectRepository(Task) private readonly taskRepository: Repository, @InjectRepository(CalendarEvent) private readonly eventRepository: Repository, @InjectRepository(User) private readonly userRepository: Repository, ) {} async regenerateSchedule(userId: string): Promise<{ scheduledCount: number; tasks: Task[] }> { const user = await this.userRepository.findOne({ where: { id: userId } }); if (!user) { throw new Error('User not found'); } const workingHours = user.workingHours || DEFAULT_WORKING_HOURS; // Get unscheduled NEXT_ACTION tasks that are not locked const unscheduledTasks = await this.taskRepository.find({ where: { userId, status: TaskStatus.NEXT_ACTION, isLocked: false, scheduledStart: IsNull(), }, order: { priority: { direction: 'ASC', nulls: 'LAST' }, dueDate: { direction: 'ASC', nulls: 'LAST' }, createdAt: 'ASC', }, }); if (unscheduledTasks.length === 0) { return { scheduledCount: 0, tasks: [] }; } // Get existing calendar events for the next 14 days const startDate = new Date(); const endDate = addDays(startDate, 14); const calendarEvents = await this.eventRepository.find({ where: { userId, }, }); // Get already scheduled tasks (locked ones) const scheduledTasks = await this.taskRepository.find({ where: { userId, scheduledStart: Not(IsNull()), }, }); // Build list of blocked time slots const blockedSlots: TimeSlot[] = [ ...calendarEvents.map((e) => ({ start: e.startTime, end: e.endTime })), ...scheduledTasks .filter((t) => t.scheduledStart && t.scheduledEnd) .map((t) => ({ start: t.scheduledStart!, end: t.scheduledEnd! })), ]; // Group tasks by context for batching const tasksByContext = this.groupTasksByContext(unscheduledTasks); const scheduledResults: Task[] = []; // Process each context group for (const [, tasks] of Object.entries(tasksByContext)) { // Sort by priority and due date within context const sortedTasks = tasks.sort((a, b) => { if (a.priority !== null && b.priority !== null) { return a.priority - b.priority; } if (a.priority !== null) return -1; if (b.priority !== null) return 1; if (a.dueDate && b.dueDate) { return a.dueDate.getTime() - b.dueDate.getTime(); } if (a.dueDate) return -1; if (b.dueDate) return 1; return 0; }); for (const task of sortedTasks) { const slot = this.findNextAvailableSlot( blockedSlots, workingHours, task.estimatedDuration || 30, startDate, endDate, ); if (slot) { task.scheduledStart = slot.start; task.scheduledEnd = slot.end; await this.taskRepository.save(task); scheduledResults.push(task); // Add this slot to blocked slots for next iteration blockedSlots.push(slot); } } } this.logger.log(`Scheduled ${scheduledResults.length} tasks for user ${userId}`); return { scheduledCount: scheduledResults.length, tasks: scheduledResults }; } private groupTasksByContext(tasks: Task[]): Record { const groups: Record = {}; for (const task of tasks) { const context = task.context || 'ANYWHERE'; if (!groups[context]) { groups[context] = []; } groups[context].push(task); } return groups; } private findNextAvailableSlot( blockedSlots: TimeSlot[], workingHours: WorkingHours, durationMinutes: number, searchStart: Date, searchEnd: Date, ): TimeSlot | null { let currentDate = startOfDay(searchStart); const maxIterations = 100; let iterations = 0; while (isBefore(currentDate, searchEnd) && iterations < maxIterations) { iterations++; const dayOfWeek = getDay(currentDate); const dayName = DAY_NAMES[dayOfWeek]; const dayHours = workingHours[dayName]; if (!dayHours) { currentDate = addDays(currentDate, 1); continue; } // Parse working hours const [startHour, startMin] = dayHours.start.split(':').map(Number); const [endHour, endMin] = dayHours.end.split(':').map(Number); let slotStart = setMinutes(setHours(currentDate, startHour), startMin); const dayEnd = setMinutes(setHours(currentDate, endHour), endMin); // If searching from today, start from now if we're within working hours if ( currentDate.toDateString() === searchStart.toDateString() && isAfter(searchStart, slotStart) ) { slotStart = this.roundToNext15Minutes(searchStart); } // Try to find a slot in this day while (isBefore(slotStart, dayEnd)) { const slotEnd = addMinutes(slotStart, durationMinutes); // Check if slot ends within working hours if (isAfter(slotEnd, dayEnd)) { break; } // Check if slot overlaps with any blocked slot const slot: TimeSlot = { start: slotStart, end: slotEnd }; const hasConflict = blockedSlots.some((blocked) => areIntervalsOverlapping( { start: slot.start, end: slot.end }, { start: blocked.start, end: blocked.end }, ), ); if (!hasConflict) { // Check context constraints (e.g., work contexts during work hours) if (this.isValidContextSlot()) { return slot; } } // Move to next 15-minute slot slotStart = addMinutes(slotStart, 15); } // Move to next day currentDate = addDays(currentDate, 1); } return null; } private roundToNext15Minutes(date: Date): Date { const minutes = date.getMinutes(); const remainder = minutes % 15; if (remainder === 0) { return date; } return addMinutes(date, 15 - remainder); } private isValidContextSlot(): boolean { // All contexts are valid during working hours for now // Could add more specific rules later (e.g., phone calls only 9-5) return true; } async clearSchedule(userId: string): Promise { const result = await this.taskRepository.update( { userId, isLocked: false, scheduledStart: Not(IsNull()) }, { scheduledStart: null, scheduledEnd: null }, ); return result.affected || 0; } }