- 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>
266 lines
7.6 KiB
TypeScript
266 lines
7.6 KiB
TypeScript
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;
|
|
}
|
|
}
|