← Back to Code Samples
Scheduling Algorithm

Rule-Driven Employee Break Scheduling

A scheduling engine that transforms configurable rest and meal rules into legally constrained, conflict-free break placements while accounting for employee eligibility, rolling work windows, meal suppression, and operational day parts.

C# .NET Algorithms Domain Logic Scheduling

Turning break policies into a schedule

Employee break requirements are more complex than placing a rest break at a fixed point during every shift.

Different rules may apply based on the employee, shift length, role, age, break type, or the amount of consecutive time worked. Some rules recur throughout longer shifts, while meal breaks may satisfy an otherwise required rest break.

The BreakScheduler turns those configurable rules into concrete ShiftBreak records without embedding a single fixed break schedule into the application.

Separate policy from placement

A break rule describes a requirement. It does not necessarily describe the exact time at which the break should occur.

The scheduler therefore translates rules through several stages before producing a final schedule.

01 Filter Determine which rules apply to the employee.
02 Generate Convert rules into individual break requirements.
03 Calculate Establish legal windows and preferred placement.
04 Place Find valid, non-overlapping break times.
05 Suppress Allow qualifying meals to satisfy rest requirements.
06 Resolve Associate breaks with the correct operational day part.
07 Map Produce application-level ShiftBreak records.

A staged scheduling pipeline

The public scheduling method acts primarily as an orchestration layer. Each scheduling concern is delegated to a focused operation.

BreakScheduler.cs
public async Task<IReadOnlyList<ShiftBreak>> ScheduleBreaks(
    int shiftId,
    DateTime shiftStart,
    DateTime shiftEnd,
    ManagedUser? user,
    IReadOnlyList<BreakRuleDTO> rules,
    IReadOnlyList<DayPart> allDayParts)
{
    if (!rules.Any())
        return [];

    var applicableRules =
        rules.Where(r => RuleApplies(r, user, shiftStart));

    List<RequiredBreak> required = [];

    foreach (var rule in applicableRules)
        required.AddRange(
            GenerateRequiredBreaks(
                rule,
                shiftStart,
                shiftEnd));

    var ideals =
        CalculateIdealTimes(
            required,
            shiftStart,
            shiftEnd);

    // Place first
    var placedIdeals =
        PlaceBreaks(
            ideals,
            shiftStart,
            shiftEnd);

    // Then suppress
    placedIdeals =
        SuppressRestBreaksAfterPlacement(
            placedIdeals,
            shiftStart,
            shiftEnd);

    var shiftDayParts =
        GetDayPartsForShift(
            shiftStart,
            shiftEnd,
            allDayParts);

    return MapToShiftBreaks(
        placedIdeals,
        shiftId,
        shiftStart,
        shiftEnd,
        shiftDayParts);
}

Key design choice: break requirements, ideal placement, final placement, and persisted schedule records are treated as different concepts.

Generating requirements from rules

Rules can be scoped to specific employees using criteria such as age or role before they enter the scheduling pipeline.

Once applicable rules are identified, the scheduler distinguishes between rules based on consecutive minutes worked and rules triggered when a shift length equals or exceeds a configured threshold.

GenerateRequiredBreaks
if (rule.Rule.TriggerType ==
    ScheduleBreakTrigger.ConsecutiveMinutesWorked)
{
    if (rule.Rule.TriggerMode == TriggerMode.Once)
    {
        yield return CreateRequired(rule, 0, false);
        yield break;
    }
    else
    {
        for (var t = 0;
             t < shiftMinutes;
             t += rule.Rule.TriggerValue)
        {
            if (shiftStart.AddMinutes(
                    t + rule.Rule.TriggerValue) > shiftEnd)
                yield break;

            yield return CreateRequired(rule, t, true);
        }
    }
}
else if (rule.Rule.TriggerType ==
         ScheduleBreakTrigger.ShiftLengthEqualsOrExceeds)
{
    yield return CreateRequired(rule, 0, false);
    yield break;
}

Preferred time versus valid time

Every generated requirement carries the information necessary to establish a legal scheduling window. The scheduler then calculates a preferred position inside that window rather than treating that preferred time as mandatory.

This distinction becomes important when several breaks compete for nearby times. A break can move away from its ideal position while remaining inside its allowed window.

IdealBreak
public DateTime IdealStart
{
    get
    {
        var start =
            PreferredStart.AddMinutes(
                RequiredBreak.BreakLengthMinutes / 2.0);

        start = start.RoundToNearestMinutes(5);

        var min = LegalWindowStart;

        var max =
            LegalWindowEnd.AddMinutes(
                -RequiredBreak.BreakLengthMinutes);

        return start.Clamp(min, max);
    }
}

public DateTime IdealEnd =>
    IdealStart.AddMinutes(
        RequiredBreak.BreakLengthMinutes);

Searching around the ideal

Break placement uses the ideal time as the starting point and searches outward in five-minute increments.

Each candidate must remain inside the break's legal window, finish before the shift ends, and avoid overlapping a break that has already been placed.

FindPlacement
for (var offset = 0; offset <= 30; offset += 5)
{
    foreach (var t in new[]
    {
        ideal.IdealStart.AddMinutes(-offset),
        ideal.IdealStart.AddMinutes(+offset)
    })
    {
        var end =
            t.AddMinutes(
                ideal.RequiredBreak.BreakLengthMinutes);

        if (t < ideal.LegalWindowStart ||
            end > ideal.LegalWindowEnd ||
            end > shiftEnd)
            continue;

        if (occupied.Any(
            o => t < o.End && end > o.Start))
            continue;

        return t;
    }
}

throw new InvalidOperationException(
    $"Unable to place {ideal.RequiredBreak.BreakType} break.");

Meal breaks can satisfy rest requirements

Some meal rules are configured to satisfy a rest-break requirement. Suppression is evaluated after placement so the scheduler can reason about where the meal actually occurs relative to the rest window.

A meal must overlap the rest window, and the scheduler also checks the amount of consecutive work following the meal before removing the rest requirement.

MealActuallySuppressesRest
// Must overlap rest window
if (mealEnd <= restWindowStart ||
    mealStart >= restWindowEnd)
{
    return false;
}

// Check future work segment
var nextBreak = all
    .Where(b => b.IdealStart > mealEnd)
    .OrderBy(b => b.IdealStart)
    .FirstOrDefault();

var nextStart =
    nextBreak?.IdealStart ?? shiftEnd;

var minutesAfterMeal =
    (nextStart - mealEnd).TotalMinutes;

// If more than 4 hours work after meal,
// the meal cannot suppress the rest.
if (minutesAfterMeal >= 240)
    return false;

return true;

Resolving operational day parts

Break placement also needs to fit the operational model of the restaurant. Once a break has been scheduled, the engine determines which day part owns that break.

This logic accounts for shifts and day parts that cross midnight and adjusts comparable times so they can be evaluated on a continuous timeline.

ResolveDayPart
var breakTime =
    breakStart.GetComparableTime();

var shiftStartTime =
    shiftStart.GetComparableTime();

var shiftEndTime =
    shiftEnd.GetComparableTime();

var shiftCrossesMidnight =
    shiftEndTime < shiftStartTime;

if (shiftCrossesMidnight &&
    breakTime < shiftStartTime)
{
    breakTime += 1440;
}

foreach (var dayPart in dayParts)
{
    var comparableStartTime =
        dayPart.StartTime.GetComparableTime();

    var comparableEndTime =
        dayPart.EndTime.GetComparableTime();

    if (comparableEndTime < comparableStartTime)
        comparableEndTime += 1440;

    if (breakTime >= comparableStartTime &&
        breakTime < (comparableEndTime - 120))
    {
        match = dayPart;
        break;
    }
}

Mapping the result back to the schedule

The scheduling algorithm works with its own intermediate models until placement is complete. Only then are the results mapped into the application's ShiftBreak model.

MapToShiftBreaks
result.Add(new ShiftBreak
{
    ShiftBreakId =
        ordered[i].RequiredBreak.ShiftBreakId ?? -1,

    ShiftId = shiftId,

    BreakNumber = i + 1,

    BreakType =
        ordered[i].RequiredBreak.BreakType,

    IsPaid =
        ordered[i].RequiredBreak.IsPaid,

    ScheduledStart = start,
    ScheduledEnd = end,

    DayPartId =
        ResolveDayPart(
            start,
            shiftStart,
            shiftEnd,
            dayParts),

    BreakRuleId =
        ordered[i].RequiredBreak.BreakRuleId,

    RequiredMinutes =
        ordered[i].RequiredBreak.BreakLengthMinutes
});

Why this structure?

Rule-driven

Scheduling behavior comes from configurable break rules rather than hard-coded shift-length scenarios.

Intermediate models

RequiredBreak and IdealBreak keep policy evaluation separate from the final ShiftBreak representation.

Legal windows

Breaks have a range of valid placements instead of a single predetermined timestamp.

Deterministic placement

Candidate times are searched in predictable five-minute increments around the ideal position.

Post-placement suppression

Meal and rest interaction is based on actual scheduled placement rather than theoretical rule timing.

Operational day parts

Final breaks are associated with operational day parts, including shifts that cross midnight.