Skip to content

Add heuristic forecasting and capacity-planning service#790

Merged
Chris0Jeky merged 6 commits intomainfrom
feature/forecasting-capacity-79
Apr 9, 2026
Merged

Add heuristic forecasting and capacity-planning service#790
Chris0Jeky merged 6 commits intomainfrom
feature/forecasting-capacity-79

Conversation

@Chris0Jeky
Copy link
Copy Markdown
Owner

Summary

  • Introduce ForecastingService in 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.
  • Add ForecastController with GET /api/forecast/board/{boardId} endpoint returning remaining cards, throughput stats, estimated completion date, confidence band, data point count, documented assumptions, and caveats.
  • Add forecast section to the MetricsView dashboard showing estimated completion, confidence range, caveats, and collapsible assumptions.
  • 30 unit tests covering validation, authorization, edge cases (zero throughput, no columns, single data point, large card counts, negative confidence guard), and pure helper methods.

Closes #79

Test plan

  • All 30 new ForecastingServiceTests pass
  • Full backend suite (3449 tests) passes with zero failures
  • Frontend typecheck passes
  • Manual: verify forecast endpoint returns correct JSON for a board with completed cards
  • Manual: verify MetricsView shows forecast section with confidence bands
  • Manual: verify zero-throughput board shows appropriate caveats
  • Manual: verify new board with no history shows "insufficient data" messaging

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.
Copilot AI review requested due to automatic review settings April 8, 2026 01:32
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Self-Review

Division by zero

  • SAFE: remainingCards / avgThroughput — guarded by early return when avgThroughput <= 0 (line 157)
  • SAFE: remainingCards / pessimisticThroughput — floored to 0.001 (line 181)
  • SAFE: ComputeThroughputStatisticsspanDays is Math.Max(..., 1) (line 250)

Negative confidence intervals

  • SAFE: pessimisticThroughput is floored to 0.001 so pessimisticDays is always positive (line 181)
  • Note: When stdDev > avgThroughput, the raw avg - σ would go negative, but the floor prevents it. A caveat is added in this case.

Integer overflow

  • SAFE: remainingCards is int (max ~2.1B), daysToComplete is double from division. No overflow risk.

Performance / N+1 queries

  • OK: Service makes exactly 4 queries: board lookup, columns, audit logs (capped at 10K), column card counts. Plus 1 optional GetForMetricsAsync for cycle time. No N+1 pattern.
  • Note: ComputeThroughputStatistics iterates day-by-day through the span with FirstOrDefault lookup on each — O(days * dataPoints). For typical boards (30-365 days, few hundred completions), this is fine. Could optimize with a dictionary lookup for very large spans.

Authorization

  • SAFE: Controller has [Authorize], service checks CanReadBoardAsync. Matches existing MetricsController pattern.

Edge cases handled

  • Board with no columns → caveat, empty forecast
  • Board with no done column name → falls back to rightmost
  • No completed cards (zero throughput) → null completion date, caveat
  • No remaining cards → board appears complete
  • Single data point → no confidence band, caveat about insufficient data
  • High variance → caveat about wide confidence band
  • Large card counts → no overflow

Issues found — NONE critical

  1. Minor: ComputeThroughputStatistics uses a linear scan (FirstOrDefault) per day in the span. For a 365-day window this is at most 365 iterations * N data points. Acceptable for now but could be a dictionary lookup. Not fixing — within performance budget.
  2. Minor: The historyDays validation allows null (uses default 30), but explicitly rejects 0 or negative. The pattern is < 1 or > 365 correctly handles this since null bypasses the check. Verified correct.
  3. Frontend: Low-confidence forecasts show the same UI as high-confidence ones. The caveats section mitigates this, and the confidence band section only appears when data is sufficient. Acceptable for v1.

No blocking issues found.

Replace FirstOrDefault linear scan with dictionary lookup in
ComputeThroughputStatistics for better performance on wide history windows.
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +291 to +293
var spanDays = Math.Max((latest - earliest).Days + 1, 1);

var mean = (double)totalCompletions / spanDays;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Comment on lines +368 to +375
CancellationToken cancellationToken)
{
var audits = await _unitOfWork.AuditLogs.QueryAsync(
from, to,
boardId: boardId,
limit: MaxAuditEntries,
cancellationToken: cancellationToken);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Comment on lines +186 to +190
var pessimisticThroughput = Math.Max(avgThroughput - stdDev, 0.001); // floor to avoid division by zero

var optimisticDays = remainingCards / optimisticThroughput;
var pessimisticDays = remainingCards / pessimisticThroughput;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Comment on lines +256 to +266
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;
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 new GET /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.

Comment on lines +285 to +311
// 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);

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +132 to +136
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)");
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +248 to +266
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;
}
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Review — Round 2

Findings

[HIGH] Double-counting throughput for cards moved to Done multiple times
ComputeDailyThroughput iterates all moves per card and counts every move to the done column. If a card is moved Done → In Progress → Done, it counts as 2 completions. This inflates throughput and produces an artificially optimistic forecast. Meanwhile, ComputeAverageCycleTimeAsync correctly uses only the first move to Done via OrderBy(m => m.Timestamp).FirstOrDefault(). These two methods are inconsistent.
Fix: Deduplicate — only count the last (or first) move to Done per card, not every move.

[MEDIUM] Regex compiled on every call — up to 10,000 times per forecast
ParseTargetColumnId calls Regex.Match(changes, pattern) which compiles the regex pattern fresh for each invocation. With up to MaxAuditEntries = 10_000 audit entries, this is 10K regex compilations per forecast request. Should use a private static readonly Regex (or .NET 7+ [GeneratedRegex]) for the target_column pattern.

[MEDIUM] Throughput mean uses data-point span, not the requested history window
ComputeThroughputStatistics computes spanDays from the earliest-to-latest data point, not from the full historyDays window. Example: if historyDays=30 but completions only happened on days 28-30, spanDays=3 and the mean is inflated by 10x. The full history window should be the denominator to get a true "cards per day over the requested period" rate. This is a math correctness issue that will produce misleading forecasts for boards with bursty completion patterns.

[MEDIUM] Pessimistic estimate can produce absurdly distant dates
When stdDev is close to (but less than) avgThroughput, the pessimistic throughput floors at 0.001, producing remainingCards / 0.001 days — e.g., 50 cards / 0.001 = 50,000 days (~137 years). The UI will display this as a real date. Consider capping the pessimistic estimate or adding a caveat when the pessimistic date exceeds a reasonable threshold (e.g., 2 years).

[LOW] ConfidenceBand naming is inverted in the DTO
ConfidenceBand.LowEstimate holds the optimistic (earlier) date and HighEstimate holds the pessimistic (later) date. The XML doc says "Low = optimistic, High = pessimistic" which is self-consistent, but the naming is counterintuitive — a "low estimate" usually means "lower bound" which is pessimistic in time estimation. The frontend correctly maps them but this will confuse API consumers.

[LOW] doneMove.Timestamp != default is fragile
In ComputeAverageCycleTimeAsync, FirstOrDefault() on a value tuple returns default which is (default(DateTimeOffset), default(Guid)). Checking doneMove.Timestamp != default works because default(DateTimeOffset) is 0001-01-01, but it's implicit. If an audit entry legitimately had epoch-zero timestamp, it would be silently skipped. Minor but worth a comment or a nullable wrapper.

[LOW] No test verifies the double-counting behavior
The test ComputeDailyThroughput_ShouldCountCompletionsPerDay only has single moves per card. There is no test where a card moves to Done twice. This means the HIGH issue above is untested.

[LOW] Frontend daysFromNow shows "today" for all past dates
If the estimated completion date is in the past (possible when board was already mostly done), daysFromNow returns "today" for any negative diff. Should say "overdue" or "already past" to avoid confusion.

Good practices observed

  • Authorization check before any data access — correct pattern, matches existing BoardMetricsService
  • MaxAuditEntries cap prevents unbounded memory
  • Population std dev is correct (not sample) for this use case
  • Division-by-zero protected in both the main path (avgThroughput <= 0 guard) and confidence band (Math.Max(..., 0.001))
  • Edge cases for no columns, no cards, zero throughput all handled explicitly with caveats
  • Tests cover validation, auth, empty board, zero throughput, single data point
  • Clean architecture respected — service in Application layer, no Infrastructure leaks

Verdict

PASS 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.
@Chris0Jeky Chris0Jeky merged commit 3c3d6e4 into main Apr 9, 2026
20 of 22 checks passed
@github-project-automation github-project-automation bot moved this from Pending to Done in Taskdeck Execution Apr 9, 2026
@Chris0Jeky Chris0Jeky deleted the feature/forecasting-capacity-79 branch April 9, 2026 02:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

ANL-03: Forecasting and capacity-planning service (heuristic-first)

2 participants