diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json index bd930f33..991d7445 100644 --- a/.opencode/package-lock.json +++ b/.opencode/package-lock.json @@ -5,26 +5,30 @@ "packages": { "": { "dependencies": { - "@opencode-ai/plugin": "1.2.6", - "@th0rgal/ralph-wiggum": "^1.2.1" + "@opencode-ai/plugin": "1.2.14", + "@th0rgal/ralph-wiggum": "^1.2.2" } }, "node_modules/@opencode-ai/plugin": { - "version": "1.2.6", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.2.14.tgz", + "integrity": "sha512-36dPaIaNPMjA5jnFAbOzvKe78dbUkKXF8hgs8PNRXiAaTSzoIapBC/xkADVRO66tmLyZhoGFSBkVeJUpOyyiew==", "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.2.6", + "@opencode-ai/sdk": "1.2.14", "zod": "4.1.8" } }, "node_modules/@opencode-ai/sdk": { - "version": "1.2.6", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.14.tgz", + "integrity": "sha512-nPkWAmzgPJYyfCJAV4NG7HTfN/iuO3B6fv8sT26NhPiR+EqD9i8sh4X1LwI7wEbbMOwWOX1PhrssW6gXQOOQZQ==", "license": "MIT" }, "node_modules/@th0rgal/ralph-wiggum": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@th0rgal/ralph-wiggum/-/ralph-wiggum-1.2.1.tgz", - "integrity": "sha512-8Xe6luwnKTArT9eBzyAx1newz+InGTBm9pCQrG4yiO9oYVHC0WZN3f1sQIpKwQbbnKQ4YJXdoraaatd0B4yDcA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@th0rgal/ralph-wiggum/-/ralph-wiggum-1.2.2.tgz", + "integrity": "sha512-yhDydpF8mstC+1qTz2SQxcRrMHrwnHWflBRXEUfutPN9Pm9nYMYD04n6Km0Td9xBMjw+yf3vMwNZU91nRidO7Q==", "license": "MIT", "bin": { "ralph": "bin/ralph.js" diff --git a/.ralph/ralph-history.json b/.ralph/ralph-history.json index 4b2b48b3..b44fc087 100644 --- a/.ralph/ralph-history.json +++ b/.ralph/ralph-history.json @@ -152,14 +152,27 @@ "errors": [ "\u001b[91m\u001b[1mError: \u001b[0mSession not found" ] + }, + { + "iteration": 10, + "startedAt": "2026-02-26T03:41:24.806Z", + "endedAt": "2026-02-26T03:43:03.164Z", + "durationMs": 91537, + "agent": "opencode", + "model": "", + "toolsUsed": {}, + "filesModified": [ + "openspec/changes/enhanced-allocation/" + ], + "exitCode": 1, + "completionDetected": false, + "errors": [] } ], - "totalDurationMs": 11830, + "totalDurationMs": 103367, "struggleIndicators": { - "repeatedErrors": { - "\u001b[91m\u001b[1mError: \u001b[0mSession not found": 10 - }, - "noProgressIterations": 9, - "shortIterations": 10 + "repeatedErrors": {}, + "noProgressIterations": 0, + "shortIterations": 0 } } \ No newline at end of file diff --git a/backend/app/Http/Controllers/Api/AllocationController.php b/backend/app/Http/Controllers/Api/AllocationController.php index 74b756da..67d914e0 100644 --- a/backend/app/Http/Controllers/Api/AllocationController.php +++ b/backend/app/Http/Controllers/Api/AllocationController.php @@ -59,9 +59,51 @@ class AllocationController extends Controller $allocations = $query->get(); + // Compute allocation_indicator for each allocation based on project totals + $allocations->each(function ($allocation) { + $allocation->allocation_indicator = $this->computeAllocationIndicator( + $allocation->project_id, + $allocation->month + ); + }); + return $this->wrapResource(AllocationResource::collection($allocations)); } + /** + * Compute allocation indicator based on project totals. + */ + private function computeAllocationIndicator(string $projectId, string $month): string + { + // Convert month to date format if needed + $monthDate = strlen($month) === 7 ? $month . '-01' : $month; + + // Get total allocated for this project in this month + $totalAllocated = Allocation::where('project_id', $projectId) + ->where('month', $monthDate) + ->sum('allocated_hours'); + + // Get project approved estimate + $project = \App\Models\Project::find($projectId); + $approvedEstimate = $project?->approved_estimate; + + // Handle no estimate + if (! $approvedEstimate || $approvedEstimate <= 0) { + return 'gray'; + } + + $percentage = ($totalAllocated / $approvedEstimate) * 100; + + // Check in correct order: over first, then at capacity, then under + if ($percentage > 100) { + return 'red'; + } elseif ($percentage >= 100) { + return 'green'; + } else { + return 'yellow'; + } + } + /** * Create a new allocation * @@ -89,7 +131,7 @@ class AllocationController extends Controller { $validator = Validator::make($request->all(), [ 'project_id' => 'required|uuid|exists:projects,id', - 'team_member_id' => 'required|uuid|exists:team_members,id', + 'team_member_id' => 'nullable|uuid|exists:team_members,id', 'month' => 'required|date_format:Y-m', 'allocated_hours' => 'required|numeric|min:0', ]); @@ -101,12 +143,17 @@ class AllocationController extends Controller ], 422); } - // Validate against capacity and approved estimate - $capacityValidation = $this->validationService->validateCapacity( - $request->input('team_member_id'), - $request->input('month'), - (float) $request->input('allocated_hours') - ); + // Validate against capacity and approved estimate (skip for untracked) + $teamMemberId = $request->input('team_member_id'); + $capacityValidation = ['valid' => true, 'warning' => null, 'utilization' => 0]; + + if ($teamMemberId) { + $capacityValidation = $this->validationService->validateCapacity( + $teamMemberId, + $request->input('month'), + (float) $request->input('allocated_hours') + ); + } $estimateValidation = $this->validationService->validateApprovedEstimate( $request->input('project_id'), diff --git a/backend/app/Http/Controllers/Api/ProjectMonthPlanController.php b/backend/app/Http/Controllers/Api/ProjectMonthPlanController.php new file mode 100644 index 00000000..ab50cf03 --- /dev/null +++ b/backend/app/Http/Controllers/Api/ProjectMonthPlanController.php @@ -0,0 +1,151 @@ +query('year', date('Y')); + + $startDate = "{$year}-01-01"; + $endDate = "{$year}-12-01"; + + $plans = ProjectMonthPlan::whereBetween('month', [$startDate, $endDate]) + ->with('project') + ->get() + ->groupBy('project_id'); + + // Get all active projects for the year + $projects = \App\Models\Project::where('active', true)->get(); + + // Build grid payload + $data = $projects->map(function ($project) use ($plans, $year) { + $projectPlans = $plans->get($project->id, collect()); + + $months = []; + for ($month = 1; $month <= 12; $month++) { + $monthDate = sprintf('%04d-%02d-01', $year, $month); + $plan = $projectPlans->firstWhere('month', $monthDate); + + $months[$monthDate] = $plan + ? [ + 'id' => $plan->id, + 'planned_hours' => $plan->planned_hours, + 'is_blank' => $plan->planned_hours === null, + ] + : null; + } + + return [ + 'project_id' => $project->id, + 'project_name' => $project->title, + 'approved_estimate' => $project->approved_estimate, + 'months' => $months, + ]; + }); + + // Calculate reconciliation status for each project + $data->each(function (&$project) { + $project['plan_sum'] = collect($project['months']) + ->filter(fn ($m) => $m !== null && $m['planned_hours'] !== null) + ->sum('planned_hours'); + + $approved = $project['approved_estimate'] ?? 0; + if ($approved > 0) { + if ($project['plan_sum'] > $approved) { + $project['reconciliation_status'] = 'OVER'; + } elseif ($project['plan_sum'] < $approved) { + $project['reconciliation_status'] = 'UNDER'; + } elseif ($project['plan_sum'] == $approved) { + $project['reconciliation_status'] = 'MATCH'; + } else { + $project['reconciliation_status'] = 'UNDER'; + } + } else { + $project['reconciliation_status'] = 'UNDER'; // No estimate = under + } + }); + + return response()->json([ + 'data' => $data, + 'meta' => [ + 'year' => (int) $year, + ], + ]); + } + + /** + * PUT /api/project-month-plans/bulk + * Bulk upsert month plan cells. + */ + public function bulkUpdate(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'year' => 'required|integer|min:2020|max:2100', + 'items' => 'required|array', + 'items.*.project_id' => 'required|uuid|exists:projects,id', + 'items.*.month' => 'required|date_format:Y-m', + 'items.*.planned_hours' => 'nullable|numeric|min:0', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed', + 'errors' => $validator->errors(), + ], 422); + } + + $year = $request->input('year'); + $items = $request->input('items'); + $created = 0; + $updated = 0; + $cleared = 0; + + foreach ($items as $item) { + $projectId = $item['project_id']; + $month = $item['month'] . '-01'; // Convert YYYY-MM to YYYY-MM-01 + $plannedHours = $item['planned_hours']; // Can be null to clear + + $plan = ProjectMonthPlan::firstOrNew([ + 'project_id' => $projectId, + 'month' => $month, + ]); + + if ($plannedHours === null && $plan->exists) { + // Clear semantics: delete the row to represent blank + $plan->delete(); + $cleared++; + } elseif ($plannedHours !== null) { + $plan->planned_hours = $plannedHours; + $plan->save(); + + if (!$plan->wasRecentlyCreated) { + $updated++; + } else { + $created++; + } + } + } + + return response()->json([ + 'message' => 'Bulk update complete', + 'summary' => [ + 'created' => $created, + 'updated' => $updated, + 'cleared' => $cleared, + ], + ]); + } +} diff --git a/backend/app/Http/Resources/AllocationResource.php b/backend/app/Http/Resources/AllocationResource.php index 273f3fea..07380a50 100644 --- a/backend/app/Http/Resources/AllocationResource.php +++ b/backend/app/Http/Resources/AllocationResource.php @@ -14,6 +14,7 @@ class AllocationResource extends BaseResource 'team_member_id' => $this->team_member_id, 'month' => $this->month?->format('Y-m'), 'allocated_hours' => $this->formatDecimal($this->allocated_hours), + 'allocation_indicator' => $this->allocation_indicator ?? 'gray', 'created_at' => $this->formatDate($this->created_at), 'updated_at' => $this->formatDate($this->updated_at), 'project' => $this->whenLoaded('project', fn () => new ProjectResource($this->project)), diff --git a/backend/app/Http/Resources/ProjectMonthPlanResource.php b/backend/app/Http/Resources/ProjectMonthPlanResource.php new file mode 100644 index 00000000..4ea09542 --- /dev/null +++ b/backend/app/Http/Resources/ProjectMonthPlanResource.php @@ -0,0 +1,21 @@ + $this->id, + 'project_id' => $this->project_id, + 'month' => $this->month?->format('Y-m'), + 'planned_hours' => $this->formatDecimal($this->planned_hours), + 'is_blank' => $this->planned_hours === null, + 'created_at' => $this->formatDate($this->created_at), + 'updated_at' => $this->formatDate($this->updated_at), + ]; + } +} diff --git a/backend/app/Models/ProjectMonthPlan.php b/backend/app/Models/ProjectMonthPlan.php new file mode 100644 index 00000000..e54daf3a --- /dev/null +++ b/backend/app/Models/ProjectMonthPlan.php @@ -0,0 +1,50 @@ + 'date:Y-m-01', + 'planned_hours' => 'decimal:2', + ]; + + /** + * Get the project this plan belongs to. + */ + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + /** + * Check if this plan cell is blank (unset). + */ + public function isBlank(): bool + { + return $this->planned_hours === null; + } + + /** + * Get planned hours or 0 for variance calculations. + * Blank plan is treated as 0 for allocation variance. + */ + public function getPlannedHoursForVariance(): float + { + return (float) ($this->planned_hours ?? 0); + } +} diff --git a/backend/database/migrations/2026_02_25_222332_make_team_member_id_nullable_in_allocations.php b/backend/database/migrations/2026_02_25_222332_make_team_member_id_nullable_in_allocations.php new file mode 100644 index 00000000..e7d52fb2 --- /dev/null +++ b/backend/database/migrations/2026_02_25_222332_make_team_member_id_nullable_in_allocations.php @@ -0,0 +1,28 @@ +uuid('team_member_id')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('allocations', function (Blueprint $table) { + $table->uuid('team_member_id')->nullable(false)->change(); + }); + } +}; diff --git a/backend/database/migrations/2026_02_26_000001_create_project_month_plans_table.php b/backend/database/migrations/2026_02_26_000001_create_project_month_plans_table.php new file mode 100644 index 00000000..0d244d3d --- /dev/null +++ b/backend/database/migrations/2026_02_26_000001_create_project_month_plans_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->uuid('project_id'); + $table->date('month'); // First day of month + $table->decimal('planned_hours', 10, 2)->nullable(); // null = blank/unset + $table->timestamps(); + + // Unique constraint: one plan per project per month + $table->unique(['project_id', 'month']); + + // Foreign key + $table->foreign('project_id') + ->references('id') + ->on('projects') + ->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('project_month_plans'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 39d8e118..7ae285cf 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Api\AuthController; use App\Http\Controllers\Api\CapacityController; use App\Http\Controllers\Api\HolidayController; use App\Http\Controllers\Api\ProjectController; +use App\Http\Controllers\Api\ProjectMonthPlanController; use App\Http\Controllers\Api\PtoController; use App\Http\Controllers\Api\TeamMemberController; use App\Http\Middleware\JwtAuth; @@ -42,6 +43,10 @@ Route::middleware(JwtAuth::class)->group(function () { Route::put('projects/{project}/estimate', [ProjectController::class, 'setEstimate']); Route::put('projects/{project}/forecast', [ProjectController::class, 'setForecast']); + // Project Month Plans + Route::get('/project-month-plans', [ProjectMonthPlanController::class, 'index']); + Route::put('/project-month-plans/bulk', [ProjectMonthPlanController::class, 'bulkUpdate']); + // Capacity Route::get('/capacity', [CapacityController::class, 'individual']); Route::get('/capacity/team', [CapacityController::class, 'team']); diff --git a/backend/tests/Feature/Allocation/AllocationTest.php b/backend/tests/Feature/Allocation/AllocationTest.php index fab8a22f..dfa64f79 100644 --- a/backend/tests/Feature/Allocation/AllocationTest.php +++ b/backend/tests/Feature/Allocation/AllocationTest.php @@ -257,4 +257,161 @@ class AllocationTest extends TestCase $response->assertStatus(404); } + + // ===== Allocation Indicator Tests ===== + // 1.1 API test: GET /api/allocations returns allocation_indicator per item + public function test_get_allocations_returns_allocation_indicator() + { + $token = $this->loginAsManager(); + $role = Role::factory()->create(); + $teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); + $project = Project::factory()->create(['approved_estimate' => 100]); + + Allocation::factory()->create([ + 'project_id' => $project->id, + 'team_member_id' => $teamMember->id, + 'month' => '2026-02-01', + 'allocated_hours' => 100, + ]); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/allocations?month=2026-02'); + + $response->assertStatus(200); + $response->assertJsonStructure([ + 'data' => [ + '*' => ['allocation_indicator'], + ], + ]); + } + + // 1.2 API test: allocation_indicator is green when >= 100% + public function test_allocation_indicator_is_green_when_full() + { + $token = $this->loginAsManager(); + $role = Role::factory()->create(); + $teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); + $project = Project::factory()->create(['approved_estimate' => 100]); + + Allocation::factory()->create([ + 'project_id' => $project->id, + 'team_member_id' => $teamMember->id, + 'month' => '2026-02-01', + 'allocated_hours' => 100, + ]); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/allocations?month=2026-02'); + + $response->assertStatus(200); + $response->assertJsonPath('data.0.allocation_indicator', 'green'); + } + + // 1.3 API test: allocation_indicator is yellow when < 100% + public function test_allocation_indicator_is_yellow_when_under() + { + $token = $this->loginAsManager(); + $role = Role::factory()->create(); + $teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); + $project = Project::factory()->create(['approved_estimate' => 100]); + + Allocation::factory()->create([ + 'project_id' => $project->id, + 'team_member_id' => $teamMember->id, + 'month' => '2026-02-01', + 'allocated_hours' => 80, + ]); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/allocations?month=2026-02'); + + $response->assertStatus(200); + $response->assertJsonPath('data.0.allocation_indicator', 'yellow'); + } + + // 1.4 API test: allocation_indicator is red when > 100% + public function test_allocation_indicator_is_red_when_over() + { + $token = $this->loginAsManager(); + $role = Role::factory()->create(); + $teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); + $project = Project::factory()->create(['approved_estimate' => 100]); + + Allocation::factory()->create([ + 'project_id' => $project->id, + 'team_member_id' => $teamMember->id, + 'month' => '2026-02-01', + 'allocated_hours' => 120, + ]); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/allocations?month=2026-02'); + + $response->assertStatus(200); + $response->assertJsonPath('data.0.allocation_indicator', 'red'); + } + + // 1.5 API test: allocation_indicator is gray when no approved_estimate + public function test_allocation_indicator_is_gray_when_no_estimate() + { + $token = $this->loginAsManager(); + $role = Role::factory()->create(); + $teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); + $project = Project::factory()->create(['approved_estimate' => null]); + + Allocation::factory()->create([ + 'project_id' => $project->id, + 'team_member_id' => $teamMember->id, + 'month' => '2026-02-01', + 'allocated_hours' => 40, + ]); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/allocations?month=2026-02'); + + $response->assertStatus(200); + $response->assertJsonPath('data.0.allocation_indicator', 'gray'); + } + + // ===== Untracked Allocation Tests ===== + // 2.1 API test: POST /api/allocations accepts null team_member_id + public function test_can_create_allocation_with_null_team_member() + { + $token = $this->loginAsManager(); + $project = Project::factory()->create(); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->postJson('/api/allocations', [ + 'project_id' => $project->id, + 'team_member_id' => null, + 'month' => '2026-02', + 'allocated_hours' => 40, + ]); + + $response->assertStatus(201); + $response->assertJsonPath('data.team_member_id', null); + $response->assertJsonPath('data.team_member', null); + } + + // 2.2 API test: GET /api/allocations returns null team_member + public function test_get_allocations_returns_null_team_member() + { + $token = $this->loginAsManager(); + $project = Project::factory()->create(); + + // Create untracked allocation + Allocation::factory()->create([ + 'project_id' => $project->id, + 'team_member_id' => null, + 'month' => '2026-02-01', + 'allocated_hours' => 40, + ]); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/allocations?month=2026-02'); + + $response->assertStatus(200); + $response->assertJsonPath('data.0.team_member_id', null); + $response->assertJsonPath('data.0.team_member', null); + } } diff --git a/backend/tests/Unit/AllocationPolicyTest.php b/backend/tests/Unit/AllocationPolicyTest.php index 2f36353c..986d28ec 100644 --- a/backend/tests/Unit/AllocationPolicyTest.php +++ b/backend/tests/Unit/AllocationPolicyTest.php @@ -47,4 +47,13 @@ class AllocationPolicyTest extends TestCase $this->assertFalse($this->policy->create($developer)); } + + // 2.3 Unit test: AllocationPolicy allows untracked allocation + public function test_manager_can_create_untracked_allocations() + { + $manager = User::factory()->create(['role' => 'manager']); + + // Policy should allow creating allocations (untracked is just null team_member_id) + $this->assertTrue($this->policy->create($manager)); + } } diff --git a/docs/headroom-decision-log.md b/docs/headroom-decision-log.md index eacf7686..bdeacf4a 100644 --- a/docs/headroom-decision-log.md +++ b/docs/headroom-decision-log.md @@ -951,4 +951,69 @@ frontend/src/lib/ --- +## Allocation Fidelity Decisions + +**Date:** February 26, 2026 +**Context:** During enhanced-allocation discovery, we found drift between intended planning semantics and current implementation assumptions. These decisions lock the canonical model to avoid rework. + +### D-ALOC-01: Three-Surface Planning Chain Is Canonical + +**Decision:** Allocation planning uses a strict layered model: +1. **Projects surface**: `approved_estimate` is lifecycle total effort cap +2. **Project-Month Plan surface**: manager-entered monthly plan hours per project +3. **Project-Resource Allocation surface**: month execution by team member (and untracked) + +**Outcome:** Reporting derives from this chain and must not bypass month-plan semantics. + +### D-ALOC-02: Monthly Plan Is Explicit, Not Derived + +**Decision:** Do **not** derive monthly budget from `approved_estimate / 12`. + +**Rationale:** Delivery phasing depends on external dependencies, customer timelines, and team sequencing; equal split is misleading. + +**Example:** A 3000h project may be planned as Jan 1200, Feb 1400, Mar 400. + +### D-ALOC-03: Reconciliation Rule for Monthly Plan vs Lifecycle Total + +**Decision:** Sum of all project month-plan values is reconciled against project `approved_estimate`: +- `sum > approved_estimate` -> **OVER** +- `sum < approved_estimate` -> **UNDER** +- `sum == approved_estimate` -> **MATCH** (neutral) + +**Visual policy:** emphasize over/under; match remains neutral. + +### D-ALOC-04: Blank Month Semantics + +**Decision:** +- In planning UI, blank month cells remain blank (not auto-filled with 0) +- In allocation variance math, blank plan is treated as planned `0` +- Allocation is allowed even when month plan is blank + +**Rationale:** Preserve manager intent in planning UI while keeping deterministic execution math. + +### D-ALOC-05: Grid-First Editing Policy + +**Decision:** Use grid entry as primary interaction for planning and allocation surfaces. + +**Policy:** Modal flow is not primary for single-cell edits. + +### D-ALOC-06: Minimal Status Visual Language + +**Decision:** Keep status visuals minimal: +- **OVER** = red +- **UNDER** = amber +- **MATCH/SETTLED** = neutral (no extra green spread) + +**Guideline:** Status emphasis belongs on row/column summary edges, not heavy color in every interior cell. + +### D-ALOC-07: `forecasted_effort` Deprecated for This Workflow + +**Decision:** Stop using `projects.forecasted_effort` as planning source for this capability. + +**Status:** Development phase; safe to deprecate with no production migration burden. + +**New source of truth:** project-month planning dataset for monthly intent. + +--- + *End of Decision Log* diff --git a/frontend/src/lib/components/capacity/CapacityExpertGrid.svelte b/frontend/src/lib/components/capacity/CapacityExpertGrid.svelte index 119cdae1..9e91f983 100644 --- a/frontend/src/lib/components/capacity/CapacityExpertGrid.svelte +++ b/frontend/src/lib/components/capacity/CapacityExpertGrid.svelte @@ -138,6 +138,25 @@ }); } } + for (const date of daysInMonth) { + const key = getCellKey(member.id, date); + if (cells.has(key)) { + continue; + } + const wknd = isWeekend(date); + const hol = holidayDates.has(date); + const defaultValue: NormalizedToken = hol + ? { rawToken: 'H', numericValue: 0, valid: true } + : wknd + ? { rawToken: 'O', numericValue: 0, valid: true } + : { rawToken: '1', numericValue: 1, valid: true }; + cells.set(key, { + memberId: member.id, + date, + originalValue: defaultValue.numericValue, + currentValue: defaultValue + }); + } } loading = false; diff --git a/frontend/src/lib/services/allocationService.ts b/frontend/src/lib/services/allocationService.ts index 452d142d..09514a30 100644 --- a/frontend/src/lib/services/allocationService.ts +++ b/frontend/src/lib/services/allocationService.ts @@ -9,9 +9,10 @@ import { api } from './api'; export interface Allocation { id: string; project_id: string; - team_member_id: string; + team_member_id: string | null; month: string; allocated_hours: string; + allocation_indicator?: 'green' | 'yellow' | 'red' | 'gray'; created_at: string; updated_at: string; project?: { @@ -22,7 +23,7 @@ export interface Allocation { team_member?: { id: string; name: string; - }; + } | null; } export interface CreateAllocationRequest { diff --git a/frontend/src/lib/services/projectMonthPlanService.ts b/frontend/src/lib/services/projectMonthPlanService.ts new file mode 100644 index 00000000..a95d87ac --- /dev/null +++ b/frontend/src/lib/services/projectMonthPlanService.ts @@ -0,0 +1,53 @@ +import { api } from '$lib/services/api'; + +export interface ProjectMonthPlan { + project_id: string; + project_name: string; + approved_estimate: number; + months: Record; + plan_sum: number; + reconciliation_status: 'OVER' | 'UNDER' | 'MATCH'; +} + +export interface ProjectMonthPlansResponse { + data: ProjectMonthPlan[]; + meta: { + year: number; + }; +} + +export interface BulkUpdateRequest { + year: number; + items: Array<{ + project_id: string; + month: string; + planned_hours: number | null; + }>; +} + +export interface BulkUpdateResponse { + message: string; + summary: { + created: number; + updated: number; + cleared: number; + }; +} + +class ProjectMonthPlanService { + async getPlans(year: number): Promise { + const response = await api.get(`/project-month-plans?year=${year}`); + return response.data; + } + + async bulkUpdate(request: BulkUpdateRequest): Promise { + const response = await api.put('/project-month-plans/bulk', request); + return response.data; + } +} + +export const projectMonthPlanService = new ProjectMonthPlanService(); diff --git a/frontend/src/routes/allocations/+page.svelte b/frontend/src/routes/allocations/+page.svelte index 48a0980c..73fd4a38 100644 --- a/frontend/src/routes/allocations/+page.svelte +++ b/frontend/src/routes/allocations/+page.svelte @@ -105,6 +105,23 @@ return allocations.reduce((sum, a) => sum + parseFloat(a.allocated_hours), 0); } + function getProjectBudget(projectId: string): number { + const project = projects.find(p => p.id === projectId); + if (!project?.approved_estimate) return 0; + // Monthly budget = approved_estimate / 12 + return Math.round(Number(project.approved_estimate) / 12); + } + + function getProjectBudgetStatus(projectId: string): 'over' | 'under' | 'ok' { + const budget = getProjectBudget(projectId); + const allocated = getProjectRowTotal(projectId); + if (budget === 0) return 'under'; + const percentage = (allocated / budget) * 100; + if (percentage > 100) return 'over'; + if (percentage >= 100) return 'ok'; + return 'under'; + } + function handleCellClick(projectId: string, teamMemberId: string) { const existing = getAllocation(projectId, teamMemberId); if (existing) { @@ -252,7 +269,11 @@ onclick={() => handleCellClick(project.id, member.id)} > {#if allocation} - + {@const indicator = allocation.allocation_indicator || 'gray'} + {allocation.allocated_hours}h {:else} @@ -266,6 +287,34 @@ {/each} + + + + + Monthly Budget + {#each teamMembers as member} + - + {/each} + + {Math.round(projects.reduce((sum, p) => sum + (getProjectBudget(p.id) || 0), 0))}h + + + + Status + {#each teamMembers as member} + - + {/each} + + {#if getProjectTotal() > projects.reduce((sum, p) => sum + getProjectBudget(p.id), 0)} + OVER + {:else if getProjectTotal() < projects.reduce((sum, p) => sum + getProjectBudget(p.id), 0)} + UNDER + {:else} + OK + {/if} + + + Total diff --git a/frontend/src/routes/team-members/+page.svelte b/frontend/src/routes/team-members/+page.svelte index f1a35434..6ed91506 100644 --- a/frontend/src/routes/team-members/+page.svelte +++ b/frontend/src/routes/team-members/+page.svelte @@ -25,8 +25,8 @@ // Form state let formData = $state({ name: '', - role_id: 0, - hourly_rate: 0, + role_id: 13, // Default to first role + hourly_rate: 0, active: true }); @@ -71,16 +71,15 @@ async function loadRoles() { try { - // For now, we'll use hardcoded roles matching the backend seeder - // In a real app, you'd fetch this from an API endpoint + // Role IDs from database seeder roles = [ - { id: 1, name: 'Frontend Developer' }, - { id: 2, name: 'Backend Developer' }, - { id: 3, name: 'QA Engineer' }, - { id: 4, name: 'DevOps Engineer' }, - { id: 5, name: 'UX Designer' }, - { id: 6, name: 'Project Manager' }, - { id: 7, name: 'Architect' } + { id: 13, name: 'Frontend Dev' }, + { id: 14, name: 'Backend Dev' }, + { id: 15, name: 'QA' }, + { id: 16, name: 'DevOps' }, + { id: 17, name: 'UX' }, + { id: 18, name: 'PM' }, + { id: 19, name: 'Architect' } ]; } catch (err) { console.error('Error loading roles:', err); diff --git a/openspec/changes/enhanced-allocation/.openspec.yaml b/openspec/changes/enhanced-allocation/.openspec.yaml new file mode 100644 index 00000000..e331c975 --- /dev/null +++ b/openspec/changes/enhanced-allocation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-25 diff --git a/openspec/changes/enhanced-allocation/design.md b/openspec/changes/enhanced-allocation/design.md new file mode 100644 index 00000000..b49c3700 --- /dev/null +++ b/openspec/changes/enhanced-allocation/design.md @@ -0,0 +1,239 @@ +# Design: Enhanced Allocation Fidelity + +## Context + +The allocation experience must reflect a strict planning-to-execution model. Current implementation drift introduced incorrect month semantics (`approved_estimate / 12`), mixed concern UIs, and ambiguous status signaling. + +This design aligns implementation with the validated operating model: + +1. **Projects (Lifecycle)**: `approved_estimate` is the lifecycle total. +2. **Project-Month Plan (Intent)**: manager explicitly sets monthly planned effort per project. +3. **Project-Resource Allocation (Execution)**: for selected month, manager allocates effort by team member. +4. **Reporting (Outcome)**: historical/current/future insights built from these three layers. + +## Goals + +1. Implement explicit project-month planning as first-class data. +2. Reframe allocation matrix as execution grid with month-plan and capacity variance. +3. Support untracked effort (`team_member_id = null`) end-to-end. +4. Implement partial bulk success for allocations. +5. Keep visual signaling minimal and decision-focused. + +## Non-Goals + +- Real-time collaboration/websockets. +- Notification systems. +- Export workflows. +- Cosmetic UI experimentation beyond fidelity requirements. + +## Decisions + +### D1: Month Plan Is Explicit (No Derived Monthly Budget) + +- **Decision**: monthly plan is manager-entered and persisted. +- **Rejected**: deriving monthly budget from `approved_estimate / 12`. +- **Reason**: project phasing is dependency-driven, not uniform. + +### D2: Reconciliation vs Lifecycle Total + +For each project: +- `plan_sum = sum(non-null planned_hours across months)` +- compare against `approved_estimate` + - `plan_sum > approved_estimate` -> `OVER` + - `plan_sum < approved_estimate` -> `UNDER` + - `plan_sum == approved_estimate` -> `MATCH` + +Tolerance for MATCH should use decimal-safe comparison in implementation. + +### D3: Blank Month Semantics + +- Planning grid cell can be blank (`null`) and remains visually blank. +- Allocation variance treats missing month plan as `0`. +- Allocation is allowed when month plan is blank. + +### D4: Grid-First Editing + +- Project-month planning: inline grid editing primary. +- Allocation matrix: inline grid editing primary. +- Modal is optional/fallback, not primary path. + +### D5: Untracked Semantics + +- Untracked allocation is represented by `team_member_id = null`. +- Included in project totals and grand totals. +- Excluded from team member capacity/utilization calculations. + +### D6: Visual Status Policy + +- Over = red +- Under = amber +- Match/settled = neutral + +Status emphasis belongs on row/column summary edges and variance cells; avoid noisy color in every interior cell. + +### D7: Forecasted Effort Deprecation + +- `projects.forecasted_effort` is deprecated for this planning workflow. +- New monthly planning source of truth is explicit project-month plan data. + +## Data Model + +### New Table: `project_month_plans` + +Recommended schema: +- `id` (uuid) +- `project_id` (uuid FK -> projects.id) +- `month` (date, normalized to first day of month) +- `planned_hours` (decimal, nullable) +- `created_at`, `updated_at` +- unique index on (`project_id`, `month`) + +Notes: +- `planned_hours = null` means blank/unset plan. +- If storage policy prefers non-null planned hours, clear operation must still preserve blank UI semantics via delete row strategy. + +### Existing Tables + +- `projects.approved_estimate`: lifecycle cap (unchanged semantics) +- `allocations.team_member_id`: nullable to support untracked + +## API Design + +### Project-Month Plan APIs + +1. `GET /api/project-month-plans?year=YYYY` + - returns month-plan grid payload by project/month + - includes reconciliation status per project + +2. `PUT /api/project-month-plans/bulk` + - accepts multi-cell upsert payload + - supports setting value and clearing value (blank) + - returns updated rows + reconciliation results + +Example payload: +```json +{ + "year": 2026, + "items": [ + { "project_id": "...", "month": "2026-01", "planned_hours": 1200 }, + { "project_id": "...", "month": "2026-02", "planned_hours": 1400 }, + { "project_id": "...", "month": "2026-03", "planned_hours": 400 }, + { "project_id": "...", "month": "2026-04", "planned_hours": null } + ] +} +``` + +### Allocation APIs + +- Keep `GET /api/allocations?month=YYYY-MM` and CRUD endpoints. +- Enrich response with month-context variance metadata needed for row/column summary rendering. +- `POST /api/allocations/bulk` must support partial success. + +Partial bulk response contract: +```json +{ + "data": [{ "index": 0, "id": "...", "status": "created" }], + "failed": [{ "index": 1, "errors": { "allocated_hours": ["..."] } }], + "summary": { "created": 1, "failed": 1 } +} +``` + +## Computation Rules + +### Lifecycle Reconciliation (Project-Month Plan Surface) + +For each project: +- `plan_sum = SUM(planned_hours where month in planning horizon and value != null)` +- `status = OVER | UNDER | MATCH` compared to `approved_estimate` + +### Allocation Row Variance (Execution Surface) + +For selected month and project: +- `allocated_total = SUM(allocation.allocated_hours for project/month including untracked)` +- `planned_month = planned_hours(project, month) or 0 if missing` +- `row_variance = allocated_total - planned_month` +- status from row_variance sign + +### Allocation Column Variance (Execution Surface) + +For selected month and team member: +- `member_allocated = SUM(allocation.allocated_hours for member/month)` +- `member_capacity = computed month capacity` +- `col_variance = member_allocated - member_capacity` +- exclude `team_member_id = null` rows from this computation + +## UX Specification (Implementation-Oriented) + +### Surface A: Project-Month Plan Grid + +- Rows: projects +- Columns: months +- Right edge: row total + reconciliation status +- Bottom edge: optional month totals for planning visibility +- Cell editing: inline, keyboard-first +- Blank cells remain blank visually +- Color: only summary statuses (red/amber/neutral) + +### Surface B: Project-Resource Allocation Grid (Selected Month) + +- Rows: projects +- Columns: team members + untracked +- Right edge: project row total + row variance status vs planned month +- Bottom edge: member totals + capacity variance status +- Primary editing: inline cell edit (modal not primary) +- Color: red/amber highlights only for over/under summary states + +### Accessibility + +- Do not rely on color alone; include text labels (OVER/UNDER). +- Maintain focus and keyboard navigation in both grids. +- Preserve contrast and readable status labels. + +## Risk Register + +1. **Semantic regression risk**: reintroduction of derived monthly budget logic. + - Mitigation: explicit tests and prohibition in specs. + +2. **Blank vs zero confusion**. + - Mitigation: explicit API contract and UI behavior tests. + +3. **Untracked leakage into capacity metrics**. + - Mitigation: query filters and dedicated tests. + +4. **Bulk partial side effects**. + - Mitigation: per-item validation and clear response contract. + +## Testing Strategy + +### Unit +- Lifecycle reconciliation calculator +- Row/column variance calculators +- Blank-plan-as-zero execution logic + +### Feature/API +- Project-month plan bulk upsert and clear behavior +- Reconciliation status correctness +- Allocation month variance data correctness +- Untracked include/exclude behavior +- Partial bulk success semantics + +### Frontend Grid Tests +- Inline edit commit/cancel/clear +- Keyboard navigation +- Summary status placement and labels +- Blank visual state preservation + +### E2E +- Create lifecycle estimate project +- Enter monthly plan across months and verify reconciliation +- Execute month allocations and verify row/column variances +- Validate untracked behavior +- Validate partial bulk success handling + +## Migration & Rollout Notes + +1. Introduce project-month plan model and APIs. +2. Remove derived budget rendering in allocation UI. +3. Wire allocation UI to explicit month plan for variance. +4. Deprecate `forecasted_effort` usage in this workflow path. +5. Keep backward compatibility for existing allocation records. diff --git a/openspec/changes/enhanced-allocation/docs/reporting-api.md b/openspec/changes/enhanced-allocation/docs/reporting-api.md new file mode 100644 index 00000000..971810db --- /dev/null +++ b/openspec/changes/enhanced-allocation/docs/reporting-api.md @@ -0,0 +1,130 @@ +# Reporting API Contract + +## Overview + +The Reporting API provides aggregated resource planning and execution data for management analysis. It supports three view types (did/is/will) inferred from date ranges. + +## Endpoint + +``` +GET /api/reports/allocations +``` + +## Authentication + +Requires Bearer token authentication. + +## Query Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `start_date` | string (YYYY-MM-DD) | Yes | Start of reporting period | +| `end_date` | string (YYYY-MM-DD) | Yes | End of reporting period | +| `project_ids[]` | array of UUIDs | No | Filter by specific projects | +| `member_ids[]` | array of UUIDs | No | Filter by specific team members | + +## View Types + +The `view_type` is inferred from the date range: + +| View Type | Date Range | Description | +|-----------|------------|-------------| +| `did` | All dates in past months | Historical execution analysis | +| `is` | Includes current month | Active planning vs execution | +| `will` | All dates in future months | Forecast and capacity planning | + +## Response Structure + +```json +{ + "period": { + "start": "2026-01-01", + "end": "2026-03-31" + }, + "view_type": "will", + "projects": [ + { + "id": "uuid", + "code": "PROJ-001", + "title": "Project Name", + "approved_estimate": 3000.0, + "lifecycle_status": "MATCH", + "plan_sum": 2600.0, + "period_planned": 2600.0, + "period_allocated": 2800.0, + "period_variance": 200.0, + "period_status": "OVER", + "months": [ + { + "month": "2026-01", + "planned_hours": 1200.0, + "is_blank": false, + "allocated_hours": 1300.0, + "variance": 100.0, + "status": "OVER" + } + ] + } + ], + "members": [ + { + "id": "uuid", + "name": "Team Member", + "period_allocated": 400.0, + "projects": [ + { + "project_id": "uuid", + "project_code": "PROJ-001", + "project_title": "Project Name", + "total_hours": 400.0 + } + ] + } + ], + "aggregates": { + "total_planned": 2600.0, + "total_allocated": 2800.0, + "total_variance": 200.0, + "status": "OVER" + } +} +``` + +## Status Values + +| Status | Meaning | Visual | +|--------|---------|--------| +| `OVER` | Allocated > Planned/Capacity | 🔴 Red | +| `UNDER` | Allocated < Planned/Capacity | 🟡 Amber | +| `MATCH` | Allocated = Planned/Capacity | 🟢 Green/Neutral | + +## Blank vs Zero Distinction + +- **Blank plan** (`is_blank: true`): No plan entry exists for the month + - `planned_hours`: `null` + - Used for variance: treated as `0` + +- **Explicit zero** (`is_blank: false`, `planned_hours: 0`): Plan was explicitly set to zero + - `planned_hours`: `0` + - Distinct from blank for reporting accuracy + +## Dependencies + +This endpoint combines data from: + +- `ProjectMonthPlan` - Monthly planning data +- `Allocation` - Resource allocations +- `Project` - Project metadata (approved_estimate) +- `TeamMember` - Team member capacity + +Calculations use: +- `ReconciliationCalculator` - Lifecycle plan vs estimate +- `VarianceCalculator` - Period and monthly variances + +## Error Responses + +| Status | Condition | +|--------|-----------| +| 422 | Missing or invalid date parameters | +| 422 | `end_date` before `start_date` | +| 401 | Unauthorized (missing/invalid token) | diff --git a/openspec/changes/enhanced-allocation/proposal.md b/openspec/changes/enhanced-allocation/proposal.md new file mode 100644 index 00000000..84bf864c --- /dev/null +++ b/openspec/changes/enhanced-allocation/proposal.md @@ -0,0 +1,89 @@ +# Proposal: Enhanced Allocation Fidelity + +## Why + +The current Resource Allocation implementation diverges from intended planning behavior and creates misleading month-level signals. + +Current drift: +1. Monthly budget was treated as a derived display (`approved_estimate / 12`) rather than explicit manager planning. +2. Allocation execution and planning intent are conflated in one surface. +3. Visual signaling is noisier than needed for decision-making. +4. Untracked allocation and partial bulk semantics are only partially implemented. + +Business need: +- Managers must phase a project lifecycle estimate across months based on dependencies and customer timelines. +- Month execution must be validated against explicit month plans, not an average split. +- Management needs deterministic reporting from a strict plan -> execute chain. + +## What Changes + +This change formalizes and aligns a strict 3-surface model (with reporting as the 4th outcome surface): + +1. **Projects (Lifecycle Total)** + - `approved_estimate` remains the project-level lifecycle cap. + +2. **Project-Month Plan (Manager Intent)** + - Manager enters monthly planned hours per project in a grid. + - Monthly plans reconcile against lifecycle total (OVER/UNDER/MATCH). + - Blank planning months remain blank in UI. + +3. **Project-Resource Allocation (Month Execution)** + - Allocation grid becomes primary editing workflow (no modal-first dependency). + - Row variance compares month allocation vs project month plan. + - Column variance compares member allocation vs member month capacity. + - Untracked (`team_member_id = null`) is supported as a first-class execution path. + +4. **Reporting Outcome** + - Reporting consumes lifecycle total + month plan + resource allocation to show did/is/will views. + +## Capability Changes + +### New / Expanded Capabilities +- `monthly-budget` (reframed as project-month planning) +- `allocation-indicators` (minimal, edge-focused variance signaling) +- `untracked-allocation` (null team member end-to-end semantics) + +### Modified Capability +- `resource-allocation` (partial bulk success, month-plan-aware variance) + +## Key Rules Locked by This Change + +1. No `approved_estimate / 12` derivation for planning behavior. +2. Month plan reconciliation is explicit: + - sum(plan) > approved -> OVER + - sum(plan) < approved -> UNDER + - sum(plan) == approved -> MATCH +3. Blank planning month is visually blank, but treated as planned `0` for allocation variance. +4. Allocation for blank-plan months is allowed. +5. `projects.forecasted_effort` is deprecated for this workflow. +6. Visual language is minimal: + - OVER = red + - UNDER = amber + - MATCH = neutral + +## Impact + +### Data Model +- Introduce explicit project-month planning source of truth. +- Stop using `projects.forecasted_effort` in this workflow path. + +### API Contract +- Add/adjust project-month plan endpoints and payloads. +- Ensure allocation endpoints provide enough context for row/column variance. +- Keep partial bulk response contract explicit (created/failed/summary). + +### Frontend +- Add project-month plan grid surface. +- Refactor allocation matrix to grid-first editing and edge variance indicators. +- Keep status placement low-noise and decision-oriented. + +### Testing +- Add deterministic coverage for reconciliation math, blank-vs-zero semantics, untracked handling, and partial bulk behavior. +- Require mapped tests for every requirement scenario before task closure. + +## Non-Goals + +- Real-time collaboration/websockets in this change. +- Notification systems. +- Export workflows. +- UI polish iterations beyond required planning fidelity. diff --git a/openspec/changes/enhanced-allocation/specs/allocation-indicators/spec.md b/openspec/changes/enhanced-allocation/specs/allocation-indicators/spec.md new file mode 100644 index 00000000..908db1c1 --- /dev/null +++ b/openspec/changes/enhanced-allocation/specs/allocation-indicators/spec.md @@ -0,0 +1,74 @@ +# Allocation Indicators Specification + +## Overview + +This capability defines low-noise variance indicators for planning and execution surfaces. +Indicators are decision aids, not decorative status coloring. + +## ADDED Requirements + +### Requirement: Use minimal status palette + +The system SHALL use a minimal indicator palette: +- `OVER` -> red +- `UNDER` -> amber +- `MATCH/SETTLED` -> neutral + +#### Scenario: Match is neutral +- **GIVEN** row variance equals 0 +- **WHEN** rendering status +- **THEN** status uses neutral styling +- **AND** no additional success color emphasis is required + +### Requirement: Place indicators at summary edges + +The system SHALL prioritize indicator display on row/column summary edges. + +#### Scenario: Row-level over-allocation indicator +- **GIVEN** project row total exceeds selected month plan +- **WHEN** allocation grid renders +- **THEN** project row summary status shows `OVER` in red + +#### Scenario: Column-level over-capacity indicator +- **GIVEN** member column total exceeds member month capacity +- **WHEN** allocation grid renders +- **THEN** member column summary status shows `OVER` in red + +#### Scenario: Under-allocation indicator +- **GIVEN** row or column total is below comparison target +- **WHEN** grid renders +- **THEN** summary status shows `UNDER` in amber + +### Requirement: Keep indicators explainable + +The system SHALL provide text status labels with numeric deltas for accessibility and clarity. + +#### Scenario: Color is not sole signal +- **WHEN** status is rendered +- **THEN** UI includes text label (`OVER`/`UNDER`) and delta value + +### Requirement: Distinguish project and resource variance semantics + +Project variance and resource variance SHALL remain separate. + +#### Scenario: Project over, resource under +- **GIVEN** a project row is `OVER` +- **AND** a member column is `UNDER` +- **WHEN** indicators render +- **THEN** each axis displays its own status independently + +## MODIFIED Requirements + +### Requirement: Allocation indicator source + +**Original behavior:** project indicator compared monthly allocation directly to lifecycle estimate assumptions. + +**Updated behavior:** indicator semantics in execution surface compare: +- project row totals vs **selected month planned hours** +- member column totals vs **selected month capacity** + +### Requirement: Color usage policy + +**Original behavior:** broad RED/YELLOW/GREEN/GRAY usage in many cells. + +**Updated behavior:** minimal red/amber/neutral policy with status emphasis on summary edges. diff --git a/openspec/changes/enhanced-allocation/specs/monthly-budget/spec.md b/openspec/changes/enhanced-allocation/specs/monthly-budget/spec.md new file mode 100644 index 00000000..76873f0e --- /dev/null +++ b/openspec/changes/enhanced-allocation/specs/monthly-budget/spec.md @@ -0,0 +1,88 @@ +# Project-Month Plan Specification + +## Overview + +This capability defines explicit manager-entered monthly planning per project. +It replaces derived monthly budget assumptions and becomes the planning source of truth for allocation variance. + +## ADDED Requirements + +### Requirement: Manager enters explicit monthly plan per project + +The system SHALL allow managers to set planned hours for each project-month cell. + +#### Scenario: Set monthly plan across multiple months +- **GIVEN** project `PROJ-001` has `approved_estimate = 3000` +- **WHEN** manager sets Jan=1200, Feb=1400, Mar=400 +- **THEN** the system stores those exact values +- **AND** no derived monthly average is applied + +#### Scenario: Edit monthly plan cell inline +- **GIVEN** a month-plan grid cell contains 1200 +- **WHEN** manager edits the cell to 1100 and commits +- **THEN** the system persists 1100 +- **AND** reconciliation status recalculates immediately + +### Requirement: Reconcile month-plan sum against lifecycle approved estimate + +The system SHALL compute reconciliation status per project based on: +`sum(non-null monthly planned hours)` vs `approved_estimate`. + +#### Scenario: OVER reconciliation +- **GIVEN** `approved_estimate = 3000` +- **AND** month-plan sum is 3200 +- **THEN** status is `OVER` + +#### Scenario: UNDER reconciliation +- **GIVEN** `approved_estimate = 3000` +- **AND** month-plan sum is 2800 +- **THEN** status is `UNDER` + +#### Scenario: MATCH reconciliation +- **GIVEN** `approved_estimate = 3000` +- **AND** month-plan sum is 3000 +- **THEN** status is `MATCH` + +### Requirement: Preserve blank month semantics + +The planning grid SHALL keep unset months visually blank and semantically distinct from explicit zero. + +#### Scenario: Blank remains blank +- **GIVEN** no plan exists for April +- **WHEN** manager views planning grid +- **THEN** April cell is blank (no `0` shown) + +#### Scenario: Clear cell sets blank semantics +- **GIVEN** a month cell has planned value +- **WHEN** manager clears the cell and commits +- **THEN** the month is stored as blank/unset semantics +- **AND** planning UI displays blank + +### Requirement: Allocation variance uses blank plan as zero + +For allocation variance computation only, missing month plan SHALL be treated as planned `0`. + +#### Scenario: Allocate against blank plan month +- **GIVEN** no plan is set for selected month +- **AND** project allocations total 40h +- **WHEN** variance is computed +- **THEN** planned value used is 0 +- **AND** row variance is +40 +- **AND** allocation operation remains allowed + +### Requirement: Grid-first planning interaction + +Project-month planning SHALL be managed in a grid-first interface. + +#### Scenario: Keyboard-first editing +- **WHEN** manager navigates month-plan grid with keyboard +- **THEN** inline cell editing and commit are supported +- **AND** modal interaction is not required for normal edits + +## MODIFIED Requirements + +### Requirement: Monthly budget derivation + +**Original behavior (rejected):** monthly budget derived from `approved_estimate / 12`. + +**Updated behavior:** monthly plan values are explicit manager-entered project-month values; no derivation formula is used for planning behavior. diff --git a/openspec/changes/enhanced-allocation/specs/resource-allocation/spec.md b/openspec/changes/enhanced-allocation/specs/resource-allocation/spec.md new file mode 100644 index 00000000..30617713 --- /dev/null +++ b/openspec/changes/enhanced-allocation/specs/resource-allocation/spec.md @@ -0,0 +1,47 @@ +# Resource Allocation - Delta Specification + +This delta documents required updates to existing resource allocation behavior for fidelity with explicit month planning. + +## MODIFIED Requirements + +### Requirement: Month execution comparison target + +**Original behavior:** month execution was compared against derived or lifecycle assumptions. + +**Updated behavior:** selected month project allocation is compared against explicit project-month planned hours. + +#### Scenario: Compare row total to month plan +- **GIVEN** selected month plan for project is 1200h +- **AND** project allocations total 1300h +- **THEN** project row variance is +100h +- **AND** row status is `OVER` + +#### Scenario: Blank month plan comparison +- **GIVEN** selected month has no plan value set +- **AND** project allocations total 50h +- **THEN** comparison target is 0h +- **AND** row status is `OVER` +- **AND** allocation remains allowed + +### Requirement: Bulk allocation behavior + +**Original behavior:** all-or-nothing transaction semantics. + +**Updated behavior:** valid items SHALL be saved even if some items fail. + +#### Scenario: Partial bulk success +- **WHEN** 10 allocation items are submitted and 2 fail validation +- **THEN** 8 valid items are persisted +- **AND** failed items return per-index validation errors +- **AND** response includes summary created/failed counts + +### Requirement: Untracked execution semantics + +**Original behavior:** untracked support was ambiguous/incomplete. + +**Updated behavior:** `team_member_id = null` is valid and treated as untracked effort. + +#### Scenario: Untracked counted in project, excluded from capacity +- **WHEN** untracked allocation exists for selected month +- **THEN** project totals include it +- **AND** member capacity/utilization computations exclude it diff --git a/openspec/changes/enhanced-allocation/specs/untracked-allocation/spec.md b/openspec/changes/enhanced-allocation/specs/untracked-allocation/spec.md new file mode 100644 index 00000000..49f33f7a --- /dev/null +++ b/openspec/changes/enhanced-allocation/specs/untracked-allocation/spec.md @@ -0,0 +1,55 @@ +# Untracked Allocation Specification + +## Overview + +This capability supports external/unassigned effort by allowing allocations without a team member association. + +## ADDED Requirements + +### Requirement: Support null team member in allocation APIs + +The system SHALL allow allocation records with `team_member_id = null`. + +#### Scenario: Create untracked allocation +- **GIVEN** user has allocation create permission +- **WHEN** POST /api/allocations with `team_member_id = null` +- **THEN** allocation is created successfully + +#### Scenario: Bulk create with mixed tracked/untracked +- **GIVEN** a bulk payload contains tracked and untracked entries +- **WHEN** POST /api/allocations/bulk is executed +- **THEN** untracked entries with valid data are processed successfully + +### Requirement: Include untracked in project totals + +Untracked hours SHALL contribute to project-level and grand totals. + +#### Scenario: Project total includes untracked +- **GIVEN** project has tracked 80h and untracked 20h in selected month +- **WHEN** project row total is computed +- **THEN** row total is 100h + +### Requirement: Exclude untracked from member capacity metrics + +Untracked allocations SHALL NOT contribute to any team member utilization/capacity variance. + +#### Scenario: Member utilization ignores untracked +- **GIVEN** selected month has untracked allocations +- **WHEN** member column totals and capacity variance are computed +- **THEN** untracked rows are excluded from member computations + +### Requirement: Present untracked in execution grid + +The allocation grid SHALL expose untracked as a first-class execution bucket. + +#### Scenario: Untracked column visible +- **WHEN** manager opens allocation execution grid +- **THEN** untracked column/bucket is visible and editable + +## MODIFIED Requirements + +### Requirement: Capacity validation + +**Original behavior:** all allocations were assumed team-member-bound for capacity checks. + +**Updated behavior:** capacity validation is skipped for untracked allocations (`team_member_id = null`). diff --git a/openspec/changes/enhanced-allocation/tasks.md b/openspec/changes/enhanced-allocation/tasks.md new file mode 100644 index 00000000..0d418ad9 --- /dev/null +++ b/openspec/changes/enhanced-allocation/tasks.md @@ -0,0 +1,168 @@ +# Tasks: Enhanced Allocation Fidelity + +## Summary + +| Workstream | Status | Progress | +|------------|--------|----------| +| Artifact Fidelity Alignment | ✅ Complete | 100% | +| Project-Month Plan Capability | ⚪ Not Started | 0% | +| Allocation Grid Fidelity | ⚪ Not Started | 0% | +| Untracked + Partial Bulk Hardening | ⚪ Not Started | 0% | +| Reporting-Ready Contracts | ⚪ Not Started | 0% | +| Verification & Regression | ⚪ Not Started | 0% | + +--- + +## 0. Artifact Fidelity Alignment + +Ensure docs and OpenSpec artifacts are fully aligned before implementation. + +### Phase 1: Artifact Updates + +- [x] 0.1 Update decision log with model and rules +- [x] canonical 3-surface 0.2 Update proposal to remove derived monthly budget assumption +- [x] 0.3 Update design with explicit project-month planx] 0 architecture +- [x] 0.4 Update monthly-budget spec to explicit planning semantics +- [x] 0.5 Update allocation-indicators spec to red/amber/neutral policy +- [x] 0.6 Update untracked and resource-allocation delta specs to final semantics + +### Exit Criteria + +- [x] 0.7 No artifact references `approved_estimate / 12` as planning behavior +- [x] 0.8 All artifacts consistently define blank month semantics (blank UI, zero for variance) + +--- + +## 1. Project-Month Plan Capability + +Create explicit manager-entered month planning per project. + +### Phase 1: Tests (RED) + +- [ ] 1.1 Unit test: reconciliation status OVER when plan_sum > approved_estimate +- [ ] 1.2 Unit test: reconciliation status UNDER when plan_sum < approved_estimate +- [ ] 1.3 Unit test: reconciliation status MATCH when plan_sum == approved_estimate +- [ ] 1.4 Feature test: bulk upsert month plan cells persists values correctly +- [ ] 1.5 Feature test: clearing month plan cell preserves blank semantics +- [ ] 1.6 Feature test: blank month plan remains blank in response payload +- [ ] 1.7 Component test: planning grid inline edit commits and recalculates row status +- [ ] 1.8 E2E test: manager enters Jan/Feb/Mar plan (1200/1400/400) for 3000 project and sees MATCH + +### Phase 2: Implement (GREEN) + +- [x] 1.9 Add project-month plan persistence model/migration (project_id, month, planned_hours) +- [x] 1.10 Implement plan query endpoint for grid consumption +- [x] 1.11 Implement plan bulk upsert endpoint with clear-cell handling +- [ ] 1.12 Implement reconciliation calculator service and API exposure +- [ ] 1.13 Implement planning grid UI with keyboard-first inline editing +- [ ] 1.14 Ensure planning UI keeps blank cells blank (no implicit zero rendering) + +### Phase 3: Refactor + +- [ ] 1.15 Centralize reconciliation logic for API and reporting reuse + +### Phase 4: Document + +- [ ] 1.16 Document project-month plan API contracts and reconciliation semantics + +--- + +## 2. Allocation Grid Fidelity (Month Execution) + +Reframe allocation matrix as month execution against explicit plan and capacity. + +### Phase 1: Tests (RED) + +- [ ] 2.1 Unit test: row variance uses selected month planned value +- [ ] 2.2 Unit test: blank month plan treated as zero for row variance +- [ ] 2.3 Unit test: column variance uses member month capacity +- [ ] 2.4 Feature test: allocation response includes row/column variance context +- [ ] 2.5 Component test: allocation grid supports inline cell edit without modal +- [ ] 2.6 Component test: status placement on row/column summary edges only +- [ ] 2.7 E2E test: over-plan row displays red OVER status +- [ ] 2.8 E2E test: under-plan row displays amber UNDER status + +### Phase 2: Implement (GREEN) + +- [ ] 2.9 Remove derived monthly budget logic from allocation surface +- [ ] 2.10 Implement row variance against explicit month plan +- [ ] 2.11 Implement column variance against member capacity +- [ ] 2.12 Convert allocation UI to grid-first editing workflow +- [ ] 2.13 Keep modal only as optional fallback (not primary flow) +- [ ] 2.14 Apply minimal visual policy: red/amber/neutral with text labels + +### Phase 3: Refactor + +- [ ] 2.15 Extract shared variance calculation utilities + +### Phase 4: Document + +- [ ] 2.16 Document variance formulas and status placement rules + +--- + +## 3. Untracked + Partial Bulk Hardening + +Finalize untracked behavior and partial bulk response semantics. + +### Phase 1: Tests (RED) + +- [ ] 3.1 Feature test: single create accepts null team_member_id +- [ ] 3.2 Feature test: bulk create accepts mixed tracked/untracked payload +- [ ] 3.3 Feature test: untracked included in project/grand totals +- [ ] 3.4 Feature test: untracked excluded from member capacity calculations +- [ ] 3.5 Feature test: partial bulk persists valid rows and returns per-index failures +- [ ] 3.6 E2E test: untracked column allows inline allocation entry + +### Phase 2: Implement (GREEN) + +- [ ] 3.7 Ensure all allocation create/update/bulk validators support null team_member_id +- [ ] 3.8 Ensure capacity/utilization paths exclude null team_member_id +- [ ] 3.9 Ensure totals paths include null team_member_id in project/grand totals +- [ ] 3.10 Return deterministic partial bulk response contract (data/failed/summary) + +### Phase 3: Refactor + +- [ ] 3.11 Consolidate untracked filtering/scoping in one query abstraction + +### Phase 4: Document + +- [ ] 3.12 Document untracked semantics and partial bulk contract + +--- + +## 4. Reporting-Ready Contracts + +Prepare deterministic outputs for management reporting (did/is/will). + +### Phase 1: Tests (RED) + +- [ ] 4.1 Feature test: reporting payload includes lifecycle total, month plan, month execution, and variances +- [ ] 4.2 Feature test: historical/current/future month slices are consistent + +### Phase 2: Implement (GREEN) + +- [ ] 4.3 Expose report-oriented aggregate endpoint(s) for member and project views +- [ ] 4.4 Ensure response explicitly distinguishes blank plan vs explicit zero + +### Phase 3: Document + +- [ ] 4.5 Document reporting contract dependencies on plan and execution surfaces + +--- + +## 5. Verification & Regression Gates + +### Phase 1: Verification + +- [ ] 5.1 Run full backend unit + feature test suite +- [ ] 5.2 Run frontend component test suite for planning/allocation grids +- [ ] 5.3 Run E2E flows for planning -> allocation -> variance visibility +- [ ] 5.4 Verify login/team-member/project baseline flows unaffected + +### Phase 2: Quality Gates + +- [ ] 5.5 Confirm no implementation path uses derived `approved_estimate / 12` +- [ ] 5.6 Confirm `projects.forecasted_effort` is not used in this workflow +- [ ] 5.7 Confirm all statuses follow red/amber/neutral policy +- [ ] 5.8 Confirm every completed task has mapped passing automated tests