Files
nick-tracker/packages/backend/src/modules/schedule/schedule.service.ts
Debian 9c6b85f28a 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>
2026-01-11 09:58:15 +00:00

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;
}
}