feat: implement Phase 2 Core features for AutoScheduler GTD System
- ConnectWise Manage Integration: - ConnectWiseModule with service, controller, entity - API endpoints for connection CRUD and sync - Syncs service tickets, project tickets, zero-ticket projects - Stores ConnectWise priority/SLA in task metadata - Intelligent Calendar Scheduling: - CalendarModule with connection and event entities - Support for CalDAV, Microsoft Graph, Google Calendar providers - CalendarService with sync methods for all providers - SchedulingModule with automatic scheduling engine - Finds available slots respecting working hours - Groups tasks by context, respects priority and due dates - Interactive Calendar Week View: - FullCalendar with timeGridWeek view - Drag-and-drop task rescheduling - Tasks auto-lock when manually moved - Color-coded by context - Regenerate Schedule button Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
265
packages/backend/src/modules/schedule/schedule.service.ts
Normal file
265
packages/backend/src/modules/schedule/schedule.service.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
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<Task>,
|
||||
@InjectRepository(CalendarEvent)
|
||||
private readonly eventRepository: Repository<CalendarEvent>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
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<string, Task[]> {
|
||||
const groups: Record<string, Task[]> = {};
|
||||
|
||||
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<number> {
|
||||
const result = await this.taskRepository.update(
|
||||
{ userId, isLocked: false, scheduledStart: Not(IsNull()) },
|
||||
{ scheduledStart: null, scheduledEnd: null },
|
||||
);
|
||||
return result.affected || 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user