Conflict Resolution
Shyfts includes an advanced conflict detection system that identifies scheduling issues before they become problems. This guide covers all conflict types, severity levels, and resolution strategies.
Conflict Detection Overview
How It Works
Conflicts are automatically detected when creating or editing shifts:
// Source: src/services/schedulingService.ts:156-180
static async detectConflicts(params: ConflictCheckParams): Promise<ConflictDetection> {
const conflicts: Conflict[] = []
const warnings: Warning[] = []
// Run all conflict checks
const [
overlappingShifts,
leaveConflicts,
constraintViolations,
roomCapacity,
hoursExceeded
] = await Promise.all([
this.checkOverlappingShifts(params),
this.checkLeaveConflicts(params),
this.checkConstraintViolations(params),
this.checkRoomCapacity(params),
this.checkMaxHoursExceeded(params)
])
// Compile results
return {
hasConflicts: conflicts.length > 0,
conflicts,
warnings,
canProceed: !conflicts.some(c => c.severity === 'CRITICAL')
}
}
When Conflicts Are Checked
| Event | Checked |
|---|---|
| Creating a new shift | ✅ Yes |
| Editing an existing shift | ✅ Yes |
| Moving a shift (drag-drop) | ✅ Yes |
| Bulk shift creation | ✅ Yes (per shift) |
| Staff assignment change | ✅ Yes |
Conflict Types
1. Overlapping Shift (CRITICAL)
Staff member is already scheduled during the requested time.
// Source: src/services/schedulingService.ts:183-210
const checkOverlappingShifts = async (params: ConflictCheckParams) => {
const { data: overlaps } = await supabase
.from('shifts')
.select('id, start_time, end_time, room_id')
.eq('staff_id', params.staff_id)
.neq('id', params.exclude_id || '')
.gte('end_time', params.start_time)
.lte('start_time', params.end_time)
if (overlaps && overlaps.length > 0) {
return {
type: 'OVERLAPPING_SHIFT',
severity: 'CRITICAL',
message: `Staff member has ${overlaps.length} overlapping shift(s)`,
details: overlaps.map(s => ({
shift_id: s.id,
time: `${formatTime(s.start_time)} - ${formatTime(s.end_time)}`
}))
}
}
return null
}
Visual Indicator: Red warning with block icon
Resolution Options:
- Adjust shift times to avoid overlap
- Assign a different staff member
- Cancel or reschedule the conflicting shift
2. Staff on Approved Leave (CRITICAL)
Staff member has approved leave during the requested shift time.
// Source: src/services/schedulingService.ts:212-245
const checkLeaveConflicts = async (params: ConflictCheckParams) => {
const shiftDate = moment(params.start_time).format('YYYY-MM-DD')
const { data: leaveRequests } = await supabase
.from('leave_requests')
.select('id, leave_type_id, start_date, end_date, status')
.eq('staff_id', params.staff_id)
.eq('status', 'APPROVED')
.lte('start_date', shiftDate)
.gte('end_date', shiftDate)
if (leaveRequests && leaveRequests.length > 0) {
return {
type: 'STAFF_ON_APPROVED_LEAVE',
severity: 'CRITICAL',
message: 'Staff member is on approved leave for this date',
details: {
leave_type: leaveRequests[0].leave_type_id,
dates: `${leaveRequests[0].start_date} - ${leaveRequests[0].end_date}`
}
}
}
return null
}
Visual Indicator: Red warning with calendar-off icon
Resolution Options:
- Choose a different date
- Assign a different staff member
- Request leave modification (if appropriate)
3. Constraint Violation (CRITICAL/MEDIUM)
Staff member's working preferences or constraints are violated.
// Source: src/services/schedulingService.ts:256-290
const checkConstraintViolations = async (params: ConflictCheckParams) => {
const { data: preferences } = await supabase
.from('staff_preferences')
.select('*')
.eq('staff_id', params.staff_id)
.single()
const violations: Violation[] = []
const shiftDay = moment(params.start_time).day()
const shiftStartHour = moment(params.start_time).hour()
// Check unavailable days
if (preferences?.unavailable_days?.includes(shiftDay)) {
violations.push({
type: 'UNAVAILABLE_DAY',
severity: 'CRITICAL',
message: `Staff is unavailable on ${getDayName(shiftDay)}`
})
}
// Check preferred working hours
if (preferences?.preferred_start_hour && shiftStartHour < preferences.preferred_start_hour) {
violations.push({
type: 'OUTSIDE_PREFERRED_HOURS',
severity: 'MEDIUM',
message: `Shift starts before preferred time (${preferences.preferred_start_hour}:00)`
})
}
return violations
}
Constraint Types:
| Constraint | Severity | Description |
|---|---|---|
| Unavailable days | CRITICAL | Staff cannot work on specific days |
| Maximum hours per day | HIGH | Exceeds daily hour limit |
| Preferred hours | MEDIUM | Outside preferred working window |
| Minimum rest period | HIGH | Insufficient rest between shifts |
4. Room Capacity Exceeded (CRITICAL)
Room already has maximum allowed concurrent shifts.
// Source: src/services/schedulingService.ts:292-330
const checkRoomCapacity = async (params: ConflictCheckParams) => {
// Get room capacity
const { data: room } = await supabase
.from('rooms')
.select('id, name, capacity')
.eq('id', params.room_id)
.single()
// Count existing shifts in the time window
const { count } = await supabase
.from('shifts')
.select('*', { count: 'exact', head: true })
.eq('room_id', params.room_id)
.neq('id', params.exclude_id || '')
.gte('end_time', params.start_time)
.lte('start_time', params.end_time)
if (count !== null && room?.capacity && count >= room.capacity) {
return {
type: 'ROOM_CAPACITY_EXCEEDED',
severity: 'CRITICAL',
message: `Room "${room.name}" is at full capacity (${room.capacity})`,
details: {
current_bookings: count,
max_capacity: room.capacity
}
}
}
return null
}
Visual Indicator: Red warning with building icon
Resolution Options:
- Choose a different room
- Adjust the time to avoid overlap
- Move existing shifts to other rooms
5. Room Partially Booked (LOW)
Room has some bookings but is not at capacity.
// Source: src/services/schedulingService.ts:332-350
// This is informational, not blocking
if (count !== null && room?.capacity && count > 0 && count < room.capacity) {
return {
type: 'ROOM_PARTIALLY_BOOKED',
severity: 'LOW',
message: `Room has ${count} of ${room.capacity} slots filled`,
details: {
current_bookings: count,
remaining_capacity: room.capacity - count
}
}
}
Visual Indicator: Grey notice
Action: Informational only, no action required
6. Maximum Hours Exceeded (HIGH)
Staff member would exceed their weekly hour limit.
// Source: src/services/schedulingService.ts:352-395
const checkMaxHoursExceeded = async (params: ConflictCheckParams) => {
// Get staff max hours setting
const { data: staff } = await supabase
.from('staff')
.select('max_hours_per_week')
.eq('id', params.staff_id)
.single()
if (!staff?.max_hours_per_week) return null
// Calculate week start/end
const weekStart = moment(params.start_time).startOf('isoWeek')
const weekEnd = moment(params.start_time).endOf('isoWeek')
// Sum existing hours
const { data: weekShifts } = await supabase
.from('shifts')
.select('start_time, end_time')
.eq('staff_id', params.staff_id)
.gte('start_time', weekStart.toISOString())
.lte('end_time', weekEnd.toISOString())
const existingHours = weekShifts?.reduce((sum, s) => {
return sum + moment(s.end_time).diff(moment(s.start_time), 'hours', true)
}, 0) || 0
const newShiftHours = moment(params.end_time).diff(moment(params.start_time), 'hours', true)
if (existingHours + newShiftHours > staff.max_hours_per_week) {
return {
type: 'MAX_HOURS_EXCEEDED',
severity: 'HIGH',
message: `Would exceed weekly limit (${staff.max_hours_per_week}h)`,
details: {
current_hours: existingHours.toFixed(1),
new_shift_hours: newShiftHours.toFixed(1),
total_would_be: (existingHours + newShiftHours).toFixed(1),
max_allowed: staff.max_hours_per_week
}
}
}
return null
}
Visual Indicator: Orange warning with clock icon
Resolution Options:
- Reduce shift duration
- Assign a different staff member
- Review and adjust other shifts in the week
Severity Levels
CRITICAL (Red)
Action: Blocks shift creation
| Visual | Description |
|---|---|
| Red background | bg-red-500/10 |
| Red border | border-red-500/20 |
| Red text | text-red-400 |
| Icon | AlertTriangle |
// Source: src/components/calendar/ConflictIndicator.tsx:17-24
const getIndicatorColor = () => {
const criticalConflicts = conflicts.filter(c => c.severity === 'CRITICAL')
if (criticalConflicts.length > 0) {
return 'text-red-400 bg-red-500/10 border-red-500/20'
}
// ...
}
HIGH (Orange)
Action: Warning, allows proceed with confirmation
| Visual | Description |
|---|---|
| Orange background | bg-orange-500/10 |
| Orange border | border-orange-500/20 |
| Orange text | text-orange-400 |
| Icon | AlertCircle |
MEDIUM (Yellow)
Action: Informational warning
| Visual | Description |
|---|---|
| Yellow background | bg-yellow-500/10 |
| Yellow border | border-yellow-500/20 |
| Yellow text | text-yellow-400 |
| Icon | Info |
LOW (Grey)
Action: Notice only, no action needed
| Visual | Description |
|---|---|
| Grey background | bg-white/5 |
| Grey border | border-white/10 |
| Grey text | text-secondary-text |
| Icon | Info |
Conflict Indicator Component
Visual Display
// Source: src/components/calendar/ConflictIndicator.tsx:35-75
export function ConflictIndicator({ conflicts }: ConflictIndicatorProps) {
if (!conflicts || conflicts.length === 0) return null
const criticalConflicts = conflicts.filter(c => c.severity === 'CRITICAL')
const highConflicts = conflicts.filter(c => c.severity === 'HIGH')
const otherConflicts = conflicts.filter(c =>
c.severity === 'MEDIUM' || c.severity === 'LOW'
)
return (
<div className="space-y-2">
{/* Critical conflicts */}
{criticalConflicts.length > 0 && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20">
<div className="flex items-center gap-2 text-red-400 font-medium">
<AlertTriangle className="h-4 w-4" />
<span>{criticalConflicts.length} Critical Issue(s)</span>
</div>
<ul className="mt-2 space-y-1 text-sm text-red-300">
{criticalConflicts.map((c, i) => (
<li key={i}>• {c.message}</li>
))}
</ul>
</div>
)}
{/* High severity warnings */}
{highConflicts.length > 0 && (
<div className="p-3 rounded-lg bg-orange-500/10 border border-orange-500/20">
{/* ... */}
</div>
)}
{/* Other notices */}
{otherConflicts.length > 0 && (
<div className="p-3 rounded-lg bg-white/5 border border-white/10">
{/* ... */}
</div>
)}
</div>
)
}
Resolution Suggestions
Automatic Suggestions
The system generates resolution suggestions for conflicts:
// Source: src/services/schedulingService.ts:683-730
private static generateResolutions(conflicts: Conflict[]): ConflictResolution[] {
const resolutions: ConflictResolution[] = []
conflicts.forEach(conflict => {
switch (conflict.type) {
case 'OVERLAPPING_SHIFT':
resolutions.push({
type: 'ADJUST_TIME',
description: 'Adjust shift time to avoid overlap',
action: 'modify_time'
})
resolutions.push({
type: 'CHANGE_STAFF',
description: 'Assign a different staff member',
action: 'change_assignment'
})
break
case 'ROOM_CAPACITY_EXCEEDED':
resolutions.push({
type: 'CHANGE_ROOM',
description: 'Select a different room',
action: 'modify_room'
})
break
case 'MAX_HOURS_EXCEEDED':
resolutions.push({
type: 'REDUCE_DURATION',
description: 'Shorten shift duration',
action: 'modify_time'
})
resolutions.push({
type: 'CHANGE_STAFF',
description: 'Assign staff with available hours',
action: 'change_assignment'
})
break
}
})
return resolutions
}
Resolution Types
| Resolution | Description | Applies To |
|---|---|---|
| ADJUST_TIME | Change start/end times | Overlaps, constraints |
| CHANGE_STAFF | Assign different person | All staff-related |
| CHANGE_ROOM | Select different room | Room conflicts |
| REDUCE_DURATION | Shorten shift length | Hours exceeded |
| RESCHEDULE | Move to different date | Leave conflicts |
Conflict Workflow
Step-by-Step Process
1. User creates/edits shift
↓
2. Conflict detection runs
↓
3. Results displayed
↓
┌─────────────────────────────┐
│ No conflicts? │
│ → Proceed with creation │
└─────────────────────────────┘
↓
┌─────────────────────────────┐
│ CRITICAL conflicts? │
│ → Block + show resolution │
└─────────────────────────────┘
↓
┌─────────────────────────────┐
│ HIGH/MEDIUM warnings? │
│ → Show warning │
│ → Allow proceed with │
│ acknowledgment │
└─────────────────────────────┘
↓
4. User resolves or acknowledges
↓
5. Shift created/updated
Conflict Modal
// Source: src/components/calendar/ShiftCreateModal.tsx:425-480
{detectedConflicts && detectedConflicts.hasConflicts && (
<div className="mt-4">
<ConflictIndicator conflicts={detectedConflicts.conflicts} />
{/* Show resolutions for critical conflicts */}
{detectedConflicts.conflicts.some(c => c.severity === 'CRITICAL') && (
<div className="mt-4 p-4 rounded-lg bg-white/5">
<h4 className="text-sm font-medium text-primary-text mb-2">
Suggested Resolutions
</h4>
<ul className="space-y-2 text-sm text-secondary-text">
{detectedConflicts.resolutions?.map((r, i) => (
<li key={i} className="flex items-center gap-2">
<ArrowRight className="h-3 w-3" />
{r.description}
</li>
))}
</ul>
</div>
)}
{/* Acknowledge checkbox for non-critical */}
{!detectedConflicts.conflicts.some(c => c.severity === 'CRITICAL') && (
<label className="flex items-center gap-2 mt-4 text-sm text-secondary-text">
<input
type="checkbox"
checked={acknowledgedWarnings}
onChange={(e) => setAcknowledgedWarnings(e.target.checked)}
className="form-checkbox"
/>
I acknowledge these warnings and wish to proceed
</label>
)}
</div>
)}
Best Practices
Preventing Conflicts
- Review staff availability - Check constraints before scheduling
- Monitor room capacity - Keep track of room utilisation
- Track weekly hours - Monitor staff hours early in the week
- Plan around leave - Check approved leave before scheduling
Handling Conflicts
- Address CRITICAL first - These must be resolved
- Review HIGH warnings - Consider impact before proceeding
- Document decisions - Add notes when overriding warnings
- Communicate changes - Notify affected staff
Common Scenarios
| Scenario | Best Resolution |
|---|---|
| Staff double-booked | Assign different staff member |
| Room at capacity | Move to alternative room |
| Hours limit reached | Split shift between staff |
| Staff on leave | Reschedule or reassign |
API Response Structure
Conflict Detection Response
{
"hasConflicts": true,
"conflicts": [
{
"type": "OVERLAPPING_SHIFT",
"severity": "CRITICAL",
"message": "Staff member has 1 overlapping shift(s)",
"details": {
"shift_id": "uuid",
"time": "09:00 - 17:00"
}
}
],
"warnings": [
{
"type": "OUTSIDE_PREFERRED_HOURS",
"severity": "MEDIUM",
"message": "Shift starts before preferred time (10:00)"
}
],
"resolutions": [
{
"type": "ADJUST_TIME",
"description": "Adjust shift time to avoid overlap",
"action": "modify_time"
}
],
"canProceed": false
}
Related Documentation
- Calendar Overview - Calendar interface guide
- Creating Shifts - Add new shifts
- Editing Shifts - Modify shifts
- Staff Roles - Role constraints
Source Files:
src/services/schedulingService.ts- Conflict detection logicsrc/components/calendar/ConflictIndicator.tsx- Visual displaysrc/components/calendar/ShiftCreateModal.tsx- Form integrationsrc/types/scheduling.ts- Type definitions