Microsoft Planner's Capacity View lands as the most significant resource-management addition to the unified Planner platform since Project for the Web merged into it. For the first time, project managers can see exactly who is overloaded, by how much, and reassign work without ever leaving Planner — all backed by Dataverse's bookable resource engine. This guide walks you through every layer: architecture, licensing gates, Graph API integration, Dataverse queries, and the PowerShell automation needed to run this at enterprise scale.
1. Full System Architecture
2. Licensing Reality Check
The biggest production mistake with Capacity View will be assuming it works for all Planner users. It doesn't. The feature is gated at the plan type level, not just the user license level — both conditions must be true simultaneously.
Planner Plan 1
Basic Planner + Project for the Web access. Schedule and Grid views available. Capacity View: not available. No effort fields on tasks.
Planner Plan 3 REQUIRED
Full Capacity View access. Per-user effort tracking, team-level heatmap, drag-drop reassignment, Dataverse resource management. Minimum for this feature.
Planner Plan 5
Everything in Plan 3 plus portfolio-level capacity rollup across multiple plans/projects. Cross-project over-allocation detection. Needed for enterprise PMO scenarios.
M365 Copilot (Add-on)
AI-driven capacity suggestions — "reassign this task to reduce over-allocation." Works on top of Plan 3+. Not required for core Capacity View functionality.
3. How the Capacity Calculation Works
Understanding the math behind the heatmap prevents misreading the data. The engine runs a time-phased aggregation on a per-user, per-bucket (day or week) basis, compared against the user's working-hour baseline from their bookableresource calendar entry in Dataverse.
4. Prerequisites & Admin Setup
-
Verify Tenant Release Ring
Navigate to
Microsoft 365 Admin Center → Settings → Org settings → Organization profile → Release preferences. For the pre-GA window, you need Targeted Release (entire org or selected users). Post-GA (October 2026), Standard Release is sufficient. Check Message Center for MC announcements tagged Planner or Project. -
Enable Project for the Web at Tenant Level
Go to
Admin Center → Settings → Org settings → Project. Ensure "Turn on Project for the Web for your organization" is enabled. Without this, Premium Planner plans cannot be created and Capacity View will never appear.# Verify Project for the Web service plan is enabled on SKU Connect-MgGraph -Scopes 'Organization.Read.All' Get-MgSubscribedSku | Where-Object { $_.SkuPartNumber -like '*PROJECT*' } | Select-Object SkuPartNumber, CapabilityStatus, @{N='ConsumedUnits';E={$_.ConsumedUnits}}, @{N='PrepaidUnits';E={$_.PrepaidUnits.Enabled}} -
Assign Planner Plan 3 Licenses
The SKU part number is
PROJECTPREMIUMfor Planner Plan 3 / Project Plan 3. Use the bulk assignment script in Section 7 for production deployments. Individual assignment via Admin Center → Users → Active Users → Licenses also works for testing. -
Create or Upgrade to a Premium Plan
In Planner web, click New plan and select Premium plan (or "Create from template" for project templates). Existing basic plans cannot be converted to Premium — tasks must be migrated. Teams-connected plans: click the Planner tab in Teams, then choose to upgrade or create a new Premium plan alongside the existing one.
-
Populate Task Effort Fields
In the Grid or Board view, open each task → Task details pane → set the Effort field (hours). Set explicit Start date and Due date. Tasks without effort values register as zero hours and make the Capacity View unreliable. Use the PowerShell audit script in Section 7 to find all zero-effort tasks at scale.
-
Verify Resource Calendar in Dataverse
Each plan member needs a corresponding
bookableresourcerecord in Dataverse with a Work Hours template assigned. Navigate toPower Platform Admin Center → Environments → [your env] → Dataverse → Tables → Bookable Resourceto verify. Missing records default to 8 hours/day but will not reflect custom schedules, part-time workers, or time zones. -
Open the Capacity View
In your Premium plan, click the view switcher in the top bar → select Capacity. The view renders immediately if data is populated. Toggle between Daily and Weekly buckets using the controls in the upper-right toolbar. Use the date range picker to focus on a sprint or quarter.
5. Graph API & Dataverse Queries
The Capacity View renders from Dataverse data, not from the standard Graph Planner API. For automation, reporting, or building custom dashboards, you need to query Dataverse directly. The Graph API covers basic task CRUD; capacity data lives exclusively in Dataverse.
5.1 Standard Graph API — Task Operations
# GET all tasks in a Premium plan
GET https://graph.microsoft.com/v1.0/planner/plans/{plan-id}/tasks
# GET specific task detail (includes effort via plannerTaskDetails)
GET https://graph.microsoft.com/v1.0/planner/tasks/{task-id}/details
# PATCH a task to update assignee (triggers reassignment write-back)
PATCH https://graph.microsoft.com/v1.0/planner/tasks/{task-id}
Content-Type: application/json
If-Match: {etag}
{
"assignments": {
"{new-user-aad-id}": {
"@odata.type": "#microsoft.graph.plannerAssignment",
"orderHint": " !"
},
"{old-user-aad-id}": null
}
}
If-Match header with the current ETag from a prior GET. Stale ETags return HTTP 412 Precondition Failed. Always fetch the current ETag immediately before patching in automated pipelines.
5.2 Dataverse Web API — Capacity Data
# GET all project tasks with effort and date fields
GET https://{org}.crm.dynamics.com/api/data/v9.2/msdyn_projecttasks
?$select=msdyn_subject,msdyn_effort,msdyn_scheduledstart,
msdyn_scheduledend,msdyn_project
&$filter=msdyn_effort gt 0
&$orderby=msdyn_scheduledstart asc
# GET resource assignments with allocated hours per contour
GET https://{org}.crm.dynamics.com/api/data/v9.2/msdyn_resourceassignments
?$select=msdyn_name,msdyn_hours,msdyn_taskid,msdyn_bookableresourceid
&$expand=msdyn_bookableresourceid($select=name,msdyn_calendarid)
# GET bookable resources with calendar capacity
GET https://{org}.crm.dynamics.com/api/data/v9.2/bookableresources
?$select=name,msdyn_calendarid,bookableresourceid
&$filter=statecode eq 0
# GET confirmed bookings (cross-reference with assignments)
GET https://{org}.crm.dynamics.com/api/data/v9.2/bookableresourcebookings
?$select=name,starttime,endtime,duration,bookingstatus
&$filter=starttime ge 2026-10-01T00:00:00Z
&$orderby=starttime asc
# PATCH a resource assignment to update allocated hours (reassignment write-back)
PATCH https://{org}.crm.dynamics.com/api/data/v9.2/msdyn_resourceassignments({assignment-id})
Content-Type: application/json
{
"msdyn_bookableresourceid@odata.bind": "/bookableresources({new-resource-id})",
"msdyn_hours": 16
}
5.3 Required API Permissions
| API Target | Scope / Permission | Type | Capacity Data? |
|---|---|---|---|
| Microsoft Graph | Tasks.ReadWrite |
Delegated | ✗ Basic tasks only |
| Microsoft Graph | Tasks.ReadWrite.All |
Application | ✗ Basic tasks only |
| Dataverse Web API | user_impersonation on Dynamics CRM |
Delegated | ✓ Full capacity data |
| Dataverse Web API | App Registration + Dataverse Security Role | Application | ✓ Full capacity data |
| Power Platform Admin | Power Platform Administrator role | Entra Role | ✓ Environment + resource config |
6. Over-allocation Detection State Machine
7. Enterprise PowerShell Automation Suite
The following scripts cover the six most critical operational needs for deploying Capacity View at scale. Each script is production-ready with parameter blocks, error handling, and explicit exit codes.
Script 1 — Bulk Planner Plan 3 License Assignment with Validation
#Requires -Modules Microsoft.Graph.Users, Microsoft.Graph.Identity.DirectoryManagement
#Requires -Version 7.2
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][string]$TenantId,
[Parameter(Mandatory)][string[]]$UserPrincipalNames,
[Parameter()][string]$SkuPartNumber = 'PROJECTPREMIUM',
[Parameter()][string]$OutputCsvPath = '.\LicenseAssignment_Results.csv'
)
$ErrorActionPreference = 'Stop'
$results = [System.Collections.Generic.List[PSObject]]::new()
try {
Write-Host "[+] Connecting to Microsoft Graph..." -ForegroundColor Cyan
Connect-MgGraph -TenantId $TenantId `
-Scopes 'User.ReadWrite.All', 'Organization.Read.All', 'Directory.Read.All' `
-NoWelcome
# Resolve SKU ID
$sku = Get-MgSubscribedSku -All | Where-Object {
$_.SkuPartNumber -eq $SkuPartNumber -and
$_.CapabilityStatus -eq 'Enabled'
}
if (-not $sku) {
throw "SKU '$SkuPartNumber' not found or not enabled in tenant '$TenantId'. " +
"Verify the license has been purchased."
}
$availableUnits = $sku.PrepaidUnits.Enabled - $sku.ConsumedUnits
Write-Host "[+] SKU: $($sku.SkuPartNumber) | Available seats: $availableUnits"
if ($availableUnits -lt $UserPrincipalNames.Count) {
throw "Insufficient seats: $availableUnits available, " +
"$($UserPrincipalNames.Count) requested. Purchase additional licenses first."
}
foreach ($upn in $UserPrincipalNames) {
$record = [PSCustomObject]@{
UPN = $upn
UserId = ''
Status = ''
AlreadyHad = $false
Error = ''
Timestamp = (Get-Date -Format 'o')
}
try {
$user = Get-MgUser -UserId $upn -Property Id, DisplayName,
AssignedLicenses, UserPrincipalName -ErrorAction Stop
$record.UserId = $user.Id
$alreadyLicensed = $user.AssignedLicenses.SkuId -contains $sku.SkuId
if ($alreadyLicensed) {
$record.Status = 'AlreadyAssigned'
$record.AlreadyHad = $true
Write-Host " [=] $upn already has $SkuPartNumber — skipping" -ForegroundColor Yellow
} else {
if ($PSCmdlet.ShouldProcess($upn, "Assign $SkuPartNumber license")) {
Set-MgUserLicense -UserId $user.Id `
-AddLicenses @{ SkuId = $sku.SkuId; DisabledPlans = @() } `
-RemoveLicenses @() | Out-Null
$record.Status = 'Assigned'
Write-Host " [+] $upn → $SkuPartNumber assigned" -ForegroundColor Green
}
}
} catch {
$record.Status = 'Failed'
$record.Error = $_.Exception.Message
Write-Warning " [!] $upn failed: $($_.Exception.Message)"
}
$results.Add($record)
}
$results | Export-Csv -Path $OutputCsvPath -NoTypeInformation -Encoding UTF8
Write-Host "[+] Results exported to: $OutputCsvPath" -ForegroundColor Cyan
$summary = $results | Group-Object Status
Write-Host "`n=== SUMMARY ==="
$summary | ForEach-Object { Write-Host " $($_.Name): $($_.Count)" }
exit 0
} catch {
Write-Error "[FATAL] $($_.Exception.Message)"
exit 1
} finally {
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
}
Script 2 — Task Effort Audit: Find All Plans with Missing Effort Values
#Requires -Modules Microsoft.Graph.Planner, Microsoft.Graph.Groups
#Requires -Version 7.2
[CmdletBinding()]
param(
[Parameter()][string]$TenantId,
[Parameter()][string]$OutputCsvPath = '.\TaskEffortAudit.csv',
[Parameter()][switch]$PremiumPlansOnly
)
$ErrorActionPreference = 'Stop'
try {
Connect-MgGraph -TenantId $TenantId `
-Scopes 'Group.Read.All', 'Tasks.Read.All', 'User.Read.All' `
-NoWelcome
Write-Host "[+] Enumerating Microsoft 365 Groups with Planner plans..."
$groups = Get-MgGroup -All -Filter "resourceProvisioningOptions/Any(x:x eq 'Team')" `
-Property Id, DisplayName -PageSize 999
$auditRows = [System.Collections.Generic.List[PSObject]]::new()
$totalPlans = 0
$totalTasks = 0
$zeroEffort = 0
foreach ($group in $groups) {
try {
$plans = Get-MgGroupPlannerPlan -GroupId $group.Id -ErrorAction SilentlyContinue
if (-not $plans) { continue }
foreach ($plan in $plans) {
$totalPlans++
$tasks = Get-MgPlannerPlanTask -PlannerPlanId $plan.Id `
-All -ErrorAction SilentlyContinue
if (-not $tasks) { continue }
foreach ($task in $tasks) {
$totalTasks++
# Graph Planner API surfaces effort as null for non-Premium plans
$effortHours = $task.AdditionalProperties.'effort'
$hasStart = [bool]$task.StartDateTime
$hasDue = [bool]$task.DueDateTime
$isZero = (-not $effortHours -or $effortHours -eq 0)
if ($isZero -or -not $hasStart -or -not $hasDue) {
$zeroEffort++
$auditRows.Add([PSCustomObject]@{
GroupName = $group.DisplayName
GroupId = $group.Id
PlanTitle = $plan.Title
PlanId = $plan.Id
TaskTitle = $task.Title
TaskId = $task.Id
EffortHours = $effortHours
HasStartDate = $hasStart
HasDueDate = $hasDue
AssigneeCount = $task.Assignments.AdditionalProperties.Count
PercentComp = $task.PercentComplete
Severity = if (-not $effortHours) { 'HIGH - No Effort' }
elseif (-not $hasStart -or -not $hasDue) { 'MEDIUM - Missing Dates' }
else { 'LOW - Zero Effort' }
AuditTime = (Get-Date -Format 'o')
})
}
}
}
} catch {
Write-Verbose "Group $($group.DisplayName) skipped: $($_.Exception.Message)"
}
}
$auditRows | Export-Csv -Path $OutputCsvPath -NoTypeInformation -Encoding UTF8
Write-Host "`n=== EFFORT AUDIT SUMMARY ===" -ForegroundColor Cyan
Write-Host " Plans scanned : $totalPlans"
Write-Host " Tasks scanned : $totalTasks"
Write-Host " Issues found : $zeroEffort"
Write-Host " Report path : $OutputCsvPath"
exit 0
} catch {
Write-Error "[FATAL] $($_.Exception.Message)"
exit 1
} finally {
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
}
Script 3 — Dataverse Resource Capacity Baseline Extraction & Over-allocation CSV Export
#Requires -Version 7.2
# Dependencies: MSAL.PS module for Dataverse token acquisition
# Install-Module MSAL.PS -Scope CurrentUser
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$TenantId,
[Parameter(Mandatory)][string]$DataverseOrgUrl, # e.g. https://contoso.crm.dynamics.com
[Parameter(Mandatory)][string]$ClientId, # App registration client ID
[Parameter(Mandatory)][string]$ClientSecret,
[Parameter()][datetime]$PeriodStart = (Get-Date).Date,
[Parameter()][datetime]$PeriodEnd = (Get-Date).Date.AddDays(30),
[Parameter()][double]$AmberThreshold = 0.80,
[Parameter()][double]$RedThreshold = 1.00,
[Parameter()][string]$OutputCsvPath = '.\CapacityBaseline.csv'
)
$ErrorActionPreference = 'Stop'
function Get-DataverseToken {
param([string]$TenantId, [string]$ClientId,
[string]$ClientSecret, [string]$Resource)
$body = @{
grant_type = 'client_credentials'
client_id = $ClientId
client_secret = $ClientSecret
scope = "$Resource/.default"
}
$tokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
$response = Invoke-RestMethod -Method Post -Uri $tokenEndpoint -Body $body
return $response.access_token
}
function Invoke-DataverseRequest {
param([string]$Uri, [string]$Token)
$headers = @{
Authorization = "Bearer $Token"
'OData-MaxVersion' = '4.0'
'OData-Version' = '4.0'
Accept = 'application/json'
Prefer = 'odata.include-annotations=*'
}
$allRecords = [System.Collections.Generic.List[object]]::new()
$nextLink = $Uri
do {
$resp = Invoke-RestMethod -Method Get -Uri $nextLink -Headers $headers
$resp.value | ForEach-Object { $allRecords.Add($_) }
$nextLink = $resp.'@odata.nextLink'
} while ($nextLink)
return $allRecords
}
try {
Write-Host "[+] Acquiring Dataverse token..."
$token = Get-DataverseToken -TenantId $TenantId -ClientId $ClientId `
-ClientSecret $ClientSecret -Resource $DataverseOrgUrl
$startStr = $PeriodStart.ToString('yyyy-MM-ddT00:00:00Z')
$endStr = $PeriodEnd.ToString('yyyy-MM-ddT23:59:59Z')
# Fetch bookable resources (capacity baseline)
Write-Host "[+] Fetching bookable resources..."
$resourcesUri = "$DataverseOrgUrl/api/data/v9.2/bookableresources" +
"?`$select=name,bookableresourceid,msdyn_calendarid&`$filter=statecode eq 0"
$resources = Invoke-DataverseRequest -Uri $resourcesUri -Token $token
Write-Host " Found $($resources.Count) active resources"
# Fetch resource assignments within period
Write-Host "[+] Fetching resource assignments for period $startStr → $endStr..."
$assignUri = "$DataverseOrgUrl/api/data/v9.2/msdyn_resourceassignments" +
"?`$select=msdyn_name,msdyn_hours,msdyn_bookableresourceid,msdyn_taskid" +
"&`$expand=msdyn_bookableresourceid(`$select=name,bookableresourceid)" +
"&`$filter=createdon ge $startStr and createdon le $endStr"
$assignments = Invoke-DataverseRequest -Uri $assignUri -Token $token
Write-Host " Found $($assignments.Count) assignments"
# Build per-resource aggregated hours map
$resourceHoursMap = @{}
foreach ($a in $assignments) {
$resId = $a.msdyn_bookableresourceid.bookableresourceid
$resName= $a.msdyn_bookableresourceid.name
if (-not $resId) { continue }
if (-not $resourceHoursMap.ContainsKey($resId)) {
$resourceHoursMap[$resId] = @{
Name = $resName
TotalAssigned = 0
AssignCount = 0
}
}
$resourceHoursMap[$resId].TotalAssigned += [double]$a.msdyn_hours
$resourceHoursMap[$resId].AssignCount++
}
# Calculate capacity: 8h/day default; period = working days only
$workingDays = 0
for ($d = $PeriodStart; $d -le $PeriodEnd; $d = $d.AddDays(1)) {
if ($d.DayOfWeek -notin @([DayOfWeek]::Saturday, [DayOfWeek]::Sunday)) {
$workingDays++
}
}
$capacityHoursPerResource = $workingDays * 8
# Build output rows
$outputRows = [System.Collections.Generic.List[PSObject]]::new()
foreach ($res in $resources) {
$id = $res.bookableresourceid
$data = $resourceH