Add heuristic forecasting and capacity-planning service#790
Conversation
Introduce ForecastingService in Application layer with rolling-average
throughput, standard-deviation confidence bands, and cycle-time estimates.
ForecastController exposes GET /api/forecast/board/{boardId}. Includes
30 unit tests covering validation, edge cases, and static helpers.
Add forecast types, API client, store state, and MetricsView section showing estimated completion date, confidence bands, caveats, and collapsible assumptions. Loads forecast alongside metrics on board select.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Adversarial Self-ReviewDivision by zero
Negative confidence intervals
Integer overflow
Performance / N+1 queries
Authorization
Edge cases handled
Issues found — NONE critical
No blocking issues found. |
Replace FirstOrDefault linear scan with dictionary lookup in ComputeThroughputStatistics for better performance on wide history windows.
There was a problem hiding this comment.
Code Review
This pull request introduces a heuristic forecasting feature to estimate board completion dates based on historical throughput and cycle times. It includes the ForecastingService logic, a new API endpoint, and a frontend UI for displaying estimates with confidence bands. Feedback highlights a logic error in throughput calculation that leads to overly optimistic results, as well as performance risks in the audit log retrieval process. Other improvements suggested include handling potential date overflow exceptions, deduplicating card completions to prevent inflated metrics, and correcting the terminology from 'Cycle Time' to 'Lead Time' to match industry standards.
| var spanDays = Math.Max((latest - earliest).Days + 1, 1); | ||
|
|
||
| var mean = (double)totalCompletions / spanDays; |
There was a problem hiding this comment.
The throughput average is currently calculated using the span between the first and last completion dates found in the window (spanDays). This leads to inaccurate and overly optimistic forecasts if completions are clustered at the end of the window. For example, if 10 cards were completed yesterday in a 30-day window, the throughput is calculated as 10/day instead of 10/30 per day. The denominator should be the total number of days in the requested history window (e.g., historyDays) to reflect the actual sustained throughput over that period.
| CancellationToken cancellationToken) | ||
| { | ||
| var audits = await _unitOfWork.AuditLogs.QueryAsync( | ||
| from, to, | ||
| boardId: boardId, | ||
| limit: MaxAuditEntries, | ||
| cancellationToken: cancellationToken); | ||
|
|
There was a problem hiding this comment.
The audit log query fetches up to 10,000 generic entries and then filters for card moves in memory. This is inefficient and risky: if a board has many other actions (creations, updates, comments), the 10,000 entry limit might be exhausted by irrelevant data, leaving few or no move events for the forecast. Additionally, without an explicit OrderByDescending on timestamp, the database may return the oldest entries first. You should pass entityType: "card" and action: AuditAction.Moved directly to QueryAsync and ensure recent entries are prioritized.
| var pessimisticThroughput = Math.Max(avgThroughput - stdDev, 0.001); // floor to avoid division by zero | ||
|
|
||
| var optimisticDays = remainingCards / optimisticThroughput; | ||
| var pessimisticDays = remainingCards / pessimisticThroughput; | ||
|
|
There was a problem hiding this comment.
When pessimisticThroughput is very low (due to the 0.001 floor), pessimisticDays can become extremely large. Adding this to now may cause an ArgumentOutOfRangeException if the resulting date exceeds DateTimeOffset.MaxValue. Furthermore, if avgThroughput is already less than 0.001, the pessimistic estimate ironically becomes more optimistic than the expected one. Consider capping the estimated days and ensuring the pessimistic throughput is always less than or equal to the average.
| foreach (var (_, moves) in cardMoveAudits) | ||
| { | ||
| foreach (var (timestamp, targetColumnId) in moves) | ||
| { | ||
| if (targetColumnId != doneColumnId) continue; | ||
|
|
||
| var day = timestamp.UtcDateTime.Date; | ||
| completionsByDay.TryGetValue(day, out var count); | ||
| completionsByDay[day] = count + 1; | ||
| } | ||
| } |
There was a problem hiding this comment.
The current logic counts every move to the 'Done' column as a completion. If a card is moved to 'Done', reopened, and moved back to 'Done' within the history window, it is counted twice. This artificially inflates throughput. Consider deduplicating completions by card ID, perhaps by only counting the most recent move to the 'Done' column for each card.
| // --- Build assumptions --- | ||
| assumptions.Add($"Uses {historyDays}-day rolling history window"); | ||
| assumptions.Add("Throughput calculated as cards moved to done column per day"); | ||
| assumptions.Add("Cycle time measured from card creation to done-column arrival"); |
There was a problem hiding this comment.
The metric calculated here (time from card creation to completion) is technically 'Lead Time'. 'Cycle Time' usually measures the duration from when work actually begins (e.g., moving to an 'In Progress' column) to completion. It is recommended to use the standard industry terminology to avoid confusion for users familiar with Kanban or Agile methodologies.
There was a problem hiding this comment.
Pull request overview
Adds a deterministic, heuristic-based forecasting feature (backend endpoint + frontend dashboard section) to estimate board completion timelines from historical throughput/cycle-time data.
Changes:
- Introduces
ForecastingService+ DTOs and wires it into DI and a newGET /api/forecast/board/{boardId}endpoint. - Extends the frontend metrics area to fetch and display completion forecasts, confidence range, caveats, and assumptions.
- Adds a comprehensive unit test suite for forecasting validation and edge cases.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/taskdeck-web/src/views/MetricsView.vue | Fetches forecast alongside metrics and renders a new “Completion Forecast” dashboard section. |
| frontend/taskdeck-web/src/types/metrics.ts | Adds forecast request/response and confidence-band TypeScript types. |
| frontend/taskdeck-web/src/store/metricsStore.ts | Adds forecast state + action (fetchBoardForecast) with error handling/toast integration. |
| frontend/taskdeck-web/src/api/metricsApi.ts | Adds getBoardForecast API call. |
| backend/tests/Taskdeck.Application.Tests/Services/ForecastingServiceTests.cs | Adds unit coverage for validation and forecasting helper logic. |
| backend/src/Taskdeck.Application/Services/IForecastingService.cs | Defines the application-layer forecasting service contract. |
| backend/src/Taskdeck.Application/Services/ForecastingService.cs | Implements heuristic forecast computation, confidence banding, and cycle-time estimation. |
| backend/src/Taskdeck.Application/DTOs/ForecastingDtos.cs | Adds forecast query/response DTOs and confidence-band DTO. |
| backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs | Registers IForecastingService in DI. |
| backend/src/Taskdeck.Api/Controllers/ForecastController.cs | Adds the forecast API endpoint. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Total completions over the window | ||
| var totalCompletions = dailyThroughput.Sum(d => d.Count); | ||
|
|
||
| // Span: from earliest data point to latest, inclusive | ||
| var earliest = dailyThroughput.Min(d => d.Date); | ||
| var latest = dailyThroughput.Max(d => d.Date); | ||
| var spanDays = Math.Max((latest - earliest).Days + 1, 1); | ||
|
|
||
| var mean = (double)totalCompletions / spanDays; | ||
|
|
||
| if (spanDays == 1) | ||
| return (mean, 0); | ||
|
|
||
| // Compute std dev over the full span (including zero days) | ||
| // Use dictionary for O(1) lookup per day instead of O(n) FirstOrDefault | ||
| var countByDate = dailyThroughput.ToDictionary(d => d.Date, d => d.Count); | ||
| var sumSquaredDiff = 0.0; | ||
| for (var day = earliest; day <= latest; day = day.AddDays(1)) | ||
| { | ||
| var dayCount = countByDate.GetValueOrDefault(day, 0); | ||
| var diff = dayCount - mean; | ||
| sumSquaredDiff += diff * diff; | ||
| } | ||
|
|
||
| var variance = sumSquaredDiff / spanDays; // population std dev (not sample) | ||
| var stdDev = Math.Sqrt(variance); | ||
|
|
There was a problem hiding this comment.
ComputeThroughputStatistics computes the mean/std-dev over the span between the earliest and latest completion dates, not the requested history window (e.g., last 30 days). This can significantly inflate AverageThroughputPerDay (and shrink the estimated completion date) when completions are clustered near the end of the window. Consider passing the forecast window bounds (historyFrom/now) into the stats computation (or generating a full 1..N-day series including leading/trailing zero days) so the denominator matches HistoryDaysUsed, and update DataPointCount/confidence-band gating accordingly.
| if (doneColumn.Name.Equals(columns.OrderByDescending(c => c.Position).First().Name)) | ||
| { | ||
| if (!DoneColumnNames.Any(n => doneColumn.Name.Equals(n, StringComparison.OrdinalIgnoreCase))) | ||
| assumptions.Add($"Done column resolved to rightmost column '{doneColumn.Name}' (no well-known done name found)"); | ||
| } |
There was a problem hiding this comment.
Rightmost-column detection uses doneColumn.Name.Equals(...) against the rightmost column’s name. If multiple columns share the same name, this can incorrectly mark a non-rightmost column as “rightmost”. Compare by Id or Position (or reference equality) instead of by Name.
| internal static List<DailyThroughputPoint> ComputeDailyThroughput( | ||
| Dictionary<Guid, List<(DateTimeOffset Timestamp, Guid TargetColumnId)>> cardMoveAudits, | ||
| Guid doneColumnId, | ||
| DateTimeOffset from, | ||
| DateTimeOffset to) | ||
| { | ||
| var completionsByDay = new Dictionary<DateTime, int>(); | ||
|
|
||
| foreach (var (_, moves) in cardMoveAudits) | ||
| { | ||
| foreach (var (timestamp, targetColumnId) in moves) | ||
| { | ||
| if (targetColumnId != doneColumnId) continue; | ||
|
|
||
| var day = timestamp.UtcDateTime.Date; | ||
| completionsByDay.TryGetValue(day, out var count); | ||
| completionsByDay[day] = count + 1; | ||
| } | ||
| } |
There was a problem hiding this comment.
ComputeDailyThroughput takes from/to parameters but never uses them. Either remove these parameters or use them to filter/clip timestamps defensively so the helper is correct even if the upstream audit query ever returns out-of-range entries.
Adversarial Review — Round 2Findings[HIGH] Double-counting throughput for cards moved to Done multiple times [MEDIUM] Regex compiled on every call — up to 10,000 times per forecast [MEDIUM] Throughput mean uses data-point span, not the requested history window [MEDIUM] Pessimistic estimate can produce absurdly distant dates [LOW] [LOW] [LOW] No test verifies the double-counting behavior [LOW] Frontend Good practices observed
VerdictPASS WITH FIXES — The HIGH (double-counting) and the first MEDIUM (throughput span vs history window) are math correctness issues that will produce misleading forecasts. The Regex MEDIUM is a perf issue worth fixing now. The other items are lower priority but should be tracked. |
…pile regex - Fix double-counting when cards bounce to Done multiple times (only count last move per card) - Use full historyDays window as denominator for throughput mean/stddev instead of data-point span, preventing inflated rates for bursty patterns - Extract Regex to compiled static field to avoid recompilation per audit entry (up to 10K per forecast) - Add tests for bounce deduplication and history-window-vs-span behavior
…apacity-79 # Conflicts: # backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs # frontend/taskdeck-web/src/api/metricsApi.ts # frontend/taskdeck-web/src/views/MetricsView.vue
Add using FsCheck.Fluent, replace Arb.Generate<T>() with ArbMap.Default.ArbFor<T>().Generator, and fix Gen.ArrayOf parameter order for FsCheck 3.x.
Summary
ForecastingServicein Application layer with rolling-average throughput, standard-deviation confidence bands (low=avg+1σ, high=avg-1σ), and average cycle-time estimates. All computations are deterministic and explainable — no ML.ForecastControllerwithGET /api/forecast/board/{boardId}endpoint returning remaining cards, throughput stats, estimated completion date, confidence band, data point count, documented assumptions, and caveats.Closes #79
Test plan
ForecastingServiceTestspass