CourseKitGuides
Custom Constraints
Create custom schedule constraints for the conflict detection pipeline
Overview
CourseKit's conflict detection runs a pipeline of ScheduleConstraint implementations. You can add custom constraints alongside (or instead of) the built-in ones.
Step 1: Extend ScheduleConstraint
import { ScheduleConstraint, type ConstraintContext } from '@hfu.digital/coursekit-nestjs';
import type { MaterializedOccurrence, Conflict } from '@hfu.digital/coursekit-nestjs';
export class MinBreakConstraint extends ScheduleConstraint {
readonly type = 'min-break';
readonly description = 'Ensures minimum break between consecutive events for instructors';
constructor(private readonly minBreakMinutes: number = 15) {
super();
}
async evaluate(
occurrences: MaterializedOccurrence[],
context: ConstraintContext,
): Promise<Conflict[]> {
const conflicts: Conflict[] = [];
// Group occurrences by instructor
const instructorOccurrences = new Map<string, MaterializedOccurrence[]>();
for (const occ of occurrences) {
const instructorIds = await context.getInstructorsForEvent(occ.eventId);
for (const instructorId of instructorIds) {
if (!instructorOccurrences.has(instructorId)) {
instructorOccurrences.set(instructorId, []);
}
instructorOccurrences.get(instructorId)!.push(occ);
}
}
// Check consecutive events for each instructor
for (const [instructorId, occs] of instructorOccurrences) {
const sorted = [...occs].sort(
(a, b) => a.startTime.getTime() - b.startTime.getTime(),
);
for (let i = 0; i < sorted.length - 1; i++) {
const current = sorted[i];
const next = sorted[i + 1];
const currentEnd = new Date(
current.startTime.getTime() + current.durationMin * 60_000,
);
const gap = (next.startTime.getTime() - currentEnd.getTime()) / 60_000;
if (gap >= 0 && gap < this.minBreakMinutes) {
conflicts.push({
id: `min-break-${instructorId}-${current.eventId}-${next.eventId}`,
type: 'insufficient-break',
severity: 'warning',
message: `Instructor ${instructorId} has only ${gap}min break between events (minimum: ${this.minBreakMinutes}min)`,
involvedEventIds: [current.eventId, next.eventId],
involvedEntityIds: [instructorId],
metadata: {
gapMinutes: gap,
requiredMinutes: this.minBreakMinutes,
},
});
}
}
}
return conflicts;
}
}Step 2: Register the Constraint
Add your custom constraint to the module options:
CourseKitModule.register({
// ...storage adapters
constraints: [new MinBreakConstraint(15)],
})Custom constraints run after built-in constraints. To replace the built-ins entirely:
CourseKitModule.register({
// ...storage adapters
enableBuiltInConstraints: false,
constraints: [
new MinBreakConstraint(15),
new MyCustomOverlapConstraint(),
],
})ConstraintContext Helpers
Every constraint receives a ConstraintContext with these lookup functions:
| Helper | Returns | Description |
|---|---|---|
getInstructorsForEvent(eventId) | string[] | Instructor IDs assigned to an event |
getGroupsForEvent(eventId) | string[] | Group IDs assigned to an event |
getRoomById(roomId) | Room | null | Room details |
getGroupStudentCount(groupId) | number | Total students in a group |
isEntityAvailable(...) | { available, conflicts } | Check entity availability |
These helpers are injected by ConflictService so your constraint doesn't need to depend on storage classes directly.
More Examples
MaxDailyHoursConstraint
export class MaxDailyHoursConstraint extends ScheduleConstraint {
readonly type = 'max-daily-hours';
readonly description = 'Limits instructor teaching hours per day';
constructor(private readonly maxHours: number = 6) {
super();
}
async evaluate(occurrences: MaterializedOccurrence[], context: ConstraintContext) {
const conflicts: Conflict[] = [];
const instructorDayMinutes = new Map<string, number>();
for (const occ of occurrences) {
const instructorIds = await context.getInstructorsForEvent(occ.eventId);
const dayKey = occ.occurrenceDate.toISOString().split('T')[0];
for (const id of instructorIds) {
const key = `${id}-${dayKey}`;
const current = instructorDayMinutes.get(key) ?? 0;
instructorDayMinutes.set(key, current + occ.durationMin);
}
}
for (const [key, totalMin] of instructorDayMinutes) {
if (totalMin > this.maxHours * 60) {
const [instructorId, day] = key.split('-');
conflicts.push({
id: `max-daily-${key}`,
type: 'max-daily-hours-exceeded',
severity: 'warning',
message: `Instructor ${instructorId} has ${(totalMin / 60).toFixed(1)}h on ${day} (max: ${this.maxHours}h)`,
involvedEventIds: [],
involvedEntityIds: [instructorId],
metadata: { totalMinutes: totalMin, maxMinutes: this.maxHours * 60 },
});
}
}
return conflicts;
}
}CrossCampusTravelConstraint
export class CrossCampusTravelConstraint extends ScheduleConstraint {
readonly type = 'cross-campus-travel';
readonly description = 'Checks travel time between consecutive events on different campuses';
// Implementation would use getRoomById() to check campus,
// then compare gap time against LocationDistance records
}