In this series
Building an external scheduling engine for Dynamics 365 Project Operations. Part 1: the WBS data model · Part 2: planned work contours · Part 3: status date scheduling · Part 4: the working-time engine · Part 5: dependencies and the critical path · Part 6: making it fast · Part 7: work that arrives from outside
The working-time engine: calendars, work templates and the three functions everything else depends on
Parts 2 and 3 both leaned on something we had not built yet. The contour algorithm needs “the working days between start and finish”. The status-date sweep needs “snap to the next working instant” and “add remaining work in working time”. Every one of those phrases is a call into the same component, and if that component is wrong, nothing above it can be right.
This post builds that component from first principles: where working time is defined in Dataverse, how to expand it into something you can compute against, the three functions the rest of the engine calls, and the mistakes that only show up at scale.
Key idea
A schedule is arithmetic on working time, not on timestamps. Every date your engine writes should be the result of a calendar operation. If you find yourself writing finish = start + days anywhere, you have found a bug.
1) Where working time lives
Project Operations does not store working hours on the project or the task. It stores them three levels down, and you need all three.
| Table | What it holds | What to know |
|---|---|---|
Work hour template (msdyn_workhourtemplate) |
The named pattern a planner picks: “Mon–Fri 8h”, “10-hour shifts, 4 on 4 off”, “24/7 turnaround” | Its msdyn_calendarid column is a plain GUID, not a lookup. You cannot expand it in a query; you fetch the calendar separately. |
Calendar (calendar) |
The container the template points at | The project’s own calendar is on msdyn_project.msdyn_calendarid and is always populated. That makes it the natural fallback. |
Calendar rule (calendarrule) |
The actual recurrence: which days, which hours, breaks, exceptions | Cannot be retrieved on its own through the Web API. Read the rules through the calendar: GET /calendars(id)?$expand=calendar_calendar_rules. |
Rules nest. A weekly pattern is a rule whose inner calendar holds one rule per working day, each with a start offset and duration in minutes. Holidays and exceptions are rules with an effective date range that override the pattern. Your expansion code has to walk this structure, not read a single row.
Task-level templates. Out of the box the template is chosen per project. Part 2 mentioned that a per-task template is a customisation: a lookup on the task to a work hour template. Once you have it, the resolution order for any task is task template → project calendar → organisation default. Write that order down and enforce it in one function; you will call it from everywhere.
2) Expanding a calendar into intervals
Rules are a description. You cannot do arithmetic on a description, so the first thing the engine does with a calendar is expand it into an ordered list of working intervals over a horizon:
[ [2026-10-05 06:00, 2026-10-05 12:00),
[2026-10-05 12:30, 2026-10-05 16:30),
[2026-10-06 06:00, 2026-10-06 12:00),
... ]
A few rules for the expansion itself:
- Half-open intervals, always. Start inclusive, end exclusive. It removes an entire class of off-by-one-minute bugs at day boundaries.
- Breaks are gaps, not intervals. A 10-hour shift with a 30-minute break is two intervals totalling 9.5 hours. This is what Part 2 meant by “9.5 productive hours”.
- Exceptions override, they do not add. A holiday rule removes that day’s intervals. A “work this Saturday” exception adds intervals that the weekly pattern would not have produced.
- Shift patterns cross midnight. A night shift from 18:00 to 06:00 is one interval that spans two calendar dates. Do not split it at midnight unless you have a reason; the “working day” for contour purposes is the shift, not the date.
- Expand in the template’s time zone, store in UTC. Rules are written in local time. Daylight-saving transitions are where this bites: a “06:00 to 16:30” shift on the changeover day is 9.5 or 11.5 hours of elapsed time but still 10.5 hours of working time. Compute the interval in local time, then convert both ends to UTC.
Turnarounds and round-the-clock work
A 24/7 template is the easy case: one interval per day, no gaps, and every date operation degenerates to elapsed time. The hard case is the mixed site: day crews on Mon–Fri templates, a turnaround unit on 24/7, and contractors on 4-on-4-off. That is why the template belongs on the task, and why the resolution order matters.
3) The three functions
Every scheduling operation in this series reduces to three calls against the interval list. Get these right and test them exhaustively; everything else is composition.
nextWorkingInstant(t) → the first working instant at or after t
addWorkingMinutes(t, m) → the instant reached by consuming m working minutes from t
workingMinutesBetween(a, b) → working minutes in [a, b)
nextWorkingInstant
function nextWorkingInstant(t):
i = firstIntervalEndingAfter(t) # binary search on interval.end
if i is none: extendHorizon(); retry
return max(t, intervals[i].start)
If t is inside an interval you get t back. If it falls in a gap, a weekend or a holiday, you get the start of the next interval. This is the function that turns “the status date is a Sunday” into “remaining work resumes Monday 06:00”.
addWorkingMinutes
function addWorkingMinutes(t, m):
t = nextWorkingInstant(t)
i = intervalContaining(t)
while m > 0:
available = intervals[i].end - t
if m < available: return t + m
m -= available
i += 1
if i == len(intervals): extendHorizon()
t = intervals[i].start
return t
Note the strict less-than. Consuming exactly the remainder of an interval finishes at that interval’s end, not at the start of the next one. A task that ends at 16:30 Friday ends at 16:30 Friday, not 06:00 Monday. Planners notice.
workingMinutesBetween
function workingMinutesBetween(a, b):
total = 0
for interval in intervalsOverlapping(a, b):
total += min(b, interval.end) - max(a, interval.start)
return total
This is how you turn two stored dates back into a duration, and it must be the exact inverse of addWorkingMinutes. Test that property directly: for random t and m, workingMinutesBetween(t, addWorkingMinutes(t, m)) == m. If it ever fails, your interval expansion has an overlap or a gap.
Days versus hours
Planners think in days. The engine computes in minutes. The bridge is hours-per-day, and it belongs to the template, not to a constant. A 3-day task on an 8-hour template is 1,440 working minutes; the same 3 days on a 12-hour shift template is 2,160. Store duration in one unit (minutes or hours), derive the day count for display, and never let the two drift apart on the record.
This matters more than it sounds when work arrives from outside. An EAM system typically carries duration on the individual operations in hours, with nothing on the order header. Your engine has to sum the operations and divide by the template’s hours-per-day to get a duration in days at all, and if it silently assumes eight, every job on a shift calendar is wrong by a third.
4) The horizon: the bug you will not see coming
You cannot expand a calendar forever, so you expand it to a horizon: today plus 90 days, plus a year, whatever. Then the backward pass of the critical path calculation (Part 5) asks for a late finish beyond that horizon, and the honest answer is “I don’t know”, but what most implementations return is the last interval they have.
The symptom is unmistakable once you know it: a group of tasks whose late finish lands on exactly the same date, roughly the expansion window after the status date, with no dependency or constraint that explains it. They are not late; they are clamped.
- Expand to
max(latestDateInPlay, dueDate) + margin, where “in play” includes late dates, not just scheduled dates. - Better: make
extendHorizon()real. The three functions above already call it; let it double the window and re-expand rather than return the last interval. - Never return an interval edge as an answer to a question that went past the edge. Throw, extend, or flag. A silently clamped date is a wrong date that looks plausible.
5) Resolving one task must never break the project
Here is a failure mode we have seen in production. The engine preloads calendars for every task in a project before it runs, because the critical path needs all of them. One task, created before the template lookup was mandatory, has no template. The resolver throws. The exception rolls back the save.
The record the user was editing was fine. The one that failed was a sibling. And because the preload runs on every task update, no task in that project can be edited until someone finds and fixes the sibling, and the error message names the sibling’s GUID, not the one on screen.
Rule
Calendar resolution has a fallback chain and it always returns a calendar. Task template, then project calendar, then organisation default. Log the fallback, flag the task in the exception list, but never throw for a single task’s missing configuration.
If you inherit data where the template is already missing, backfill it with a write that bypasses your own plugins (the MSCRM.BypassCustomPluginExecution header does this in Dataverse) so the backfill does not trigger a recalculation that changes dates on progressed work.
6) Caching, because you will call these thousands of times
A status-date sweep across a program with a few hundred pushable tasks makes tens of thousands of calls into these three functions. Expansion is the expensive part; the functions themselves are binary searches.
- Expand each distinct calendar once per run, keyed by calendar id and horizon. Most sites have a handful of templates in use across thousands of tasks.
- Keep the cache in the execution context of the run (in a Dataverse plugin, the plugin execution, not a static). Calendars change; a static cache serves yesterday’s holidays.
- Resolve the task’s calendar id once and carry it on the in-memory task object. Do not re-resolve inside the dependency loop.
7) Validation checklist
- Round trip.
workingMinutesBetween(t, addWorkingMinutes(t, m)) == mfor random inputs, including inputs that start in a gap. - Boundaries. Adding exactly the remaining minutes of a shift ends at the shift end, not the next shift start.
- Snap.
nextWorkingInstantof a Sunday, a holiday, a break and a mid-shift instant. - Daylight saving. A shift across the spring and autumn changeover has the template’s working minutes, not 60 more or fewer.
- Midnight shift. A night shift is one interval and one contour entry.
- Horizon. Ask for a date one day past the horizon and confirm you get an extension, not the edge.
- Fallback. A task with no template resolves to the project calendar and the project still saves.
- Hours per day. The same 3-day duration on an 8-hour and a 12-hour template produces different minute counts and both display as 3 days.
Summary
Three functions, one interval list, a fallback chain and an honest horizon. It is not a large amount of code. It is the code everything else in this series calls, so it deserves the most tests. With it in place, Part 5 can do what Part 3 deferred: propagate a change through the dependency graph and compute early dates, late dates and float, all in working time.
Next in the series: dependencies and the critical path. Forward and backward passes over the task graph, what the due date anchors, how constraints interact with dependencies, and why the critical path calculation must run once per project rather than once per task.
Keep reading
Building something in the Power Platform?
We design and ship the systems behind these posts. Tell us what you are working on.