From d6b7215f93ba5e65e3c167b619590b30a323d81b Mon Sep 17 00:00:00 2001 From: Santhosh Janardhanan Date: Thu, 19 Feb 2026 18:09:16 -0500 Subject: [PATCH] fix(capacity): Fix three defects - caching, save, filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Slow Team Member Dropdown - Fixed - Added cached team members store with 5-minute TTL - Dropdown now loads instantly on subsequent visits 2. Error Preventing Capacity Save - Fixed - Added saveAvailability API endpoint - Added backend service method to persist availability overrides - Added proper error handling and success feedback - Cache invalidation on save 3. Filters Not Working - Fixed - Fixed PTOManager to use shared selectedMemberId - Filters now react to team member selection Test Results: - Backend: 76 passed ✅ - Frontend Unit: 10 passed ✅ - E2E: 130 passed, 24 skipped ✅ Refs: openspec/changes/headroom-foundation --- .../Controllers/Api/CapacityController.php | 38 +++++++++ .../TeamMemberAvailabilityResource.php | 20 +++++ backend/app/Services/CapacityService.php | 15 ++++ backend/routes/api.php | 1 + .../tests/Feature/Capacity/CapacityTest.php | 25 ++++++ frontend/src/lib/api/capacity.ts | 15 +++- .../lib/components/capacity/PTOManager.svelte | 3 +- frontend/src/lib/stores/teamMembers.ts | 34 ++++++++ frontend/src/routes/capacity/+page.svelte | 79 +++++++++++++++---- 9 files changed, 211 insertions(+), 19 deletions(-) create mode 100644 backend/app/Http/Resources/TeamMemberAvailabilityResource.php create mode 100644 frontend/src/lib/stores/teamMembers.ts diff --git a/backend/app/Http/Controllers/Api/CapacityController.php b/backend/app/Http/Controllers/Api/CapacityController.php index 64306be5..4c99f943 100644 --- a/backend/app/Http/Controllers/Api/CapacityController.php +++ b/backend/app/Http/Controllers/Api/CapacityController.php @@ -6,10 +6,12 @@ use App\Http\Controllers\Controller; use App\Http\Resources\CapacityResource; use App\Http\Resources\RevenueResource; use App\Http\Resources\TeamCapacityResource; +use App\Http\Resources\TeamMemberAvailabilityResource; use App\Models\TeamMember; use App\Services\CapacityService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; class CapacityController extends Controller { @@ -157,4 +159,40 @@ class CapacityController extends Controller 'member_revenues' => $memberRevenues, ])); } + + /** + * Save Team Member Availability + * + * Persist a daily availability override and refresh cached capacity totals. + * + * @group Capacity Planning + * + * @bodyParam team_member_id string required The team member UUID. + * @bodyParam date string required The date for the availability override (YYYY-MM-DD). + * @bodyParam availability numeric required The availability value (0, 0.5, 1.0). + * + * @response 201 { + * "data": { + * "team_member_id": "550e8400-e29b-41d4-a716-446655440000", + * "date": "2026-02-03", + * "availability": 0.5 + * } + * } + */ + public function saveAvailability(Request $request): JsonResponse + { + $data = $request->validate([ + 'team_member_id' => 'required|exists:team_members,id', + 'date' => 'required|date_format:Y-m-d', + 'availability' => ['required', 'numeric', Rule::in([0, 0.5, 1])], + ]); + + $entry = $this->capacityService->upsertTeamMemberAvailability( + $data['team_member_id'], + $data['date'], + (float) $data['availability'] + ); + + return $this->wrapResource(new TeamMemberAvailabilityResource($entry), 201); + } } diff --git a/backend/app/Http/Resources/TeamMemberAvailabilityResource.php b/backend/app/Http/Resources/TeamMemberAvailabilityResource.php new file mode 100644 index 00000000..8362791f --- /dev/null +++ b/backend/app/Http/Resources/TeamMemberAvailabilityResource.php @@ -0,0 +1,20 @@ +resource; + + return [ + 'team_member_id' => $availability->team_member_id, + 'date' => $availability->date?->toDateString(), + 'availability' => (float) $availability->availability, + ]; + } +} diff --git a/backend/app/Services/CapacityService.php b/backend/app/Services/CapacityService.php index 0ea778b6..9cc9990d 100644 --- a/backend/app/Services/CapacityService.php +++ b/backend/app/Services/CapacityService.php @@ -273,6 +273,21 @@ class CapacityService ->mapWithKeys(fn (TeamMemberAvailability $entry) => [$entry->date->toDateString() => (float) $entry->availability]); } + public function upsertTeamMemberAvailability(string $teamMemberId, string $date, float $availability): TeamMemberAvailability + { + $entry = TeamMemberAvailability::updateOrCreate( + ['team_member_id' => $teamMemberId, 'date' => $date], + ['availability' => $availability] + ); + + $month = Carbon::createFromFormat('Y-m-d', $date)->format('Y-m'); + + $this->forgetCapacityCacheForTeamMember($teamMemberId, [$month]); + $this->forgetCapacityCacheForMonth($month); + + return $entry; + } + /** * Create a CarbonPeriod for the given month. */ diff --git a/backend/routes/api.php b/backend/routes/api.php index 6f699ec2..a1c64d04 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -45,6 +45,7 @@ Route::middleware(JwtAuth::class)->group(function () { Route::get('/capacity', [CapacityController::class, 'individual']); Route::get('/capacity/team', [CapacityController::class, 'team']); Route::get('/capacity/revenue', [CapacityController::class, 'revenue']); + Route::post('/capacity/availability', [CapacityController::class, 'saveAvailability']); // Holidays Route::get('/holidays', [HolidayController::class, 'index']); diff --git a/backend/tests/Feature/Capacity/CapacityTest.php b/backend/tests/Feature/Capacity/CapacityTest.php index 84b8dadc..c1c9a621 100644 --- a/backend/tests/Feature/Capacity/CapacityTest.php +++ b/backend/tests/Feature/Capacity/CapacityTest.php @@ -149,6 +149,31 @@ test('4.1.16 GET /api/capacity/revenue calculates possible revenue', function () expect(round($response->json('data.possible_revenue'), 2))->toBe(round($expectedRevenue, 2)); }); +test('4.1.25 POST /api/capacity/availability saves entry', function () { + $token = loginAsManager($this); + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + $payload = [ + 'team_member_id' => $member->id, + 'date' => '2026-02-03', + 'availability' => 0.5, + ]; + + $response = $this->postJson('/api/capacity/availability', $payload, [ + 'Authorization' => "Bearer {$token}", + ]); + + $response->assertStatus(201); + $response->assertJsonPath('data.date', '2026-02-03'); + + assertDatabaseHas('team_member_daily_availabilities', [ + 'team_member_id' => $member->id, + 'date' => '2026-02-03 00:00:00', + 'availability' => 0.5, + ]); +}); + test('4.1.17 POST /api/holidays creates holiday', function () { $token = loginAsManager($this); diff --git a/frontend/src/lib/api/capacity.ts b/frontend/src/lib/api/capacity.ts index 2f68b5f5..87bbf105 100644 --- a/frontend/src/lib/api/capacity.ts +++ b/frontend/src/lib/api/capacity.ts @@ -5,7 +5,8 @@ import type { Holiday, PTO, Revenue, - TeamCapacity + TeamCapacity, + TeamMemberAvailability } from '$lib/types/capacity'; export interface CreateHolidayData { @@ -139,3 +140,15 @@ export async function createPTO(data: CreatePTOData): Promise { export async function approvePTO(id: string): Promise { return api.put(`/ptos/${id}/approve`); } + +export interface SaveAvailabilityPayload { + team_member_id: string; + date: string; + availability: 0 | 0.5 | 1; +} + +export async function saveAvailability( + data: SaveAvailabilityPayload +): Promise { + return api.post('/capacity/availability', data); +} diff --git a/frontend/src/lib/components/capacity/PTOManager.svelte b/frontend/src/lib/components/capacity/PTOManager.svelte index f634b043..b3a0bf0f 100644 --- a/frontend/src/lib/components/capacity/PTOManager.svelte +++ b/frontend/src/lib/components/capacity/PTOManager.svelte @@ -6,8 +6,7 @@ export let teamMembers: TeamMember[] = []; export let month: string; - - let selectedMemberId = ''; + export let selectedMemberId = ''; let submitting = false; let actionLoadingId: string | null = null; let error: string | null = null; diff --git a/frontend/src/lib/stores/teamMembers.ts b/frontend/src/lib/stores/teamMembers.ts new file mode 100644 index 00000000..6ec48373 --- /dev/null +++ b/frontend/src/lib/stores/teamMembers.ts @@ -0,0 +1,34 @@ +import { writable } from 'svelte/store'; +import type { TeamMember } from '$lib/services/teamMemberService'; +import { teamMemberService } from '$lib/services/teamMemberService'; + +interface CachedTeamMembers { + data: TeamMember[]; + timestamp: number; +} + +const CACHE_TTL = 5 * 60 * 1000; + +function createTeamMembersStore() { + const { subscribe, set } = writable([]); + let cache: CachedTeamMembers | null = null; + + return { + subscribe, + async load() { + if (cache && Date.now() - cache.timestamp < CACHE_TTL) { + return cache.data; + } + + const data = await teamMemberService.getAll(); + cache = { data, timestamp: Date.now() }; + set(data); + return data; + }, + invalidate() { + cache = null; + } + }; +} + +export const teamMembersStore = createTeamMembersStore(); diff --git a/frontend/src/routes/capacity/+page.svelte b/frontend/src/routes/capacity/+page.svelte index 026f4003..a0cb98a4 100644 --- a/frontend/src/routes/capacity/+page.svelte +++ b/frontend/src/routes/capacity/+page.svelte @@ -6,7 +6,6 @@ import CapacitySummary from '$lib/components/capacity/CapacitySummary.svelte'; import HolidayManager from '$lib/components/capacity/HolidayManager.svelte'; import PTOManager from '$lib/components/capacity/PTOManager.svelte'; - import { teamMemberService } from '$lib/services/teamMemberService'; import { selectedPeriod } from '$lib/stores/period'; import { holidaysStore, @@ -18,9 +17,9 @@ revenueStore, teamCapacityStore } from '$lib/stores/capacity'; - import { getIndividualCapacity } from '$lib/api/capacity'; + import { teamMembersStore } from '$lib/stores/teamMembers'; + import { getIndividualCapacity, saveAvailability } from '$lib/api/capacity'; import type { Capacity } from '$lib/types/capacity'; - import type { TeamMember } from '$lib/services/teamMemberService'; type TabKey = 'calendar' | 'summary' | 'holidays' | 'pto'; @@ -32,26 +31,20 @@ ]; let activeTab: TabKey = 'calendar'; - let teamMembers: TeamMember[] = []; let selectedMemberId = ''; let individualCapacity: Capacity | null = null; let loadingIndividual = false; let calendarError: string | null = null; + let availabilitySaving = false; + let availabilityError: string | null = null; onMount(async () => { - await loadTeamMembers(); - }); - - async function loadTeamMembers() { try { - teamMembers = await teamMemberService.getAll(); - if (!selectedMemberId && teamMembers.length) { - selectedMemberId = teamMembers[0].id; - } + await teamMembersStore.load(); } catch (error) { console.error('Unable to load team members', error); } - } + }); async function refreshIndividualCapacity(memberId: string, period: string) { if ( @@ -76,6 +69,44 @@ } } + $: if ($teamMembersStore.length) { + const exists = $teamMembersStore.some((member) => member.id === selectedMemberId); + if (!selectedMemberId || !exists) { + selectedMemberId = $teamMembersStore[0].id; + } + } + + async function handleAvailabilityChange(event: CustomEvent<{ date: string; availability: number }>) { + const { date, availability } = event.detail; + const period = $selectedPeriod; + + if (!selectedMemberId || !period) { + return; + } + + availabilitySaving = true; + availabilityError = null; + + try { + await saveAvailability({ + team_member_id: selectedMemberId, + date, + availability + }); + + await Promise.all([ + refreshIndividualCapacity(selectedMemberId, period), + loadTeamCapacity(period), + loadRevenue(period) + ]); + } catch (error) { + console.error('Failed to save availability', error); + availabilityError = 'Unable to save availability changes.'; + } finally { + availabilitySaving = false; + } + } + $: if ($selectedPeriod) { loadTeamCapacity($selectedPeriod); loadRevenue($selectedPeriod); @@ -121,7 +152,7 @@ bind:value={selectedMemberId} aria-label="Select team member" > - {#each teamMembers as member} + {#each $teamMembersStore as member} {/each} @@ -131,6 +162,17 @@
{calendarError}
{/if} + {#if availabilityError} +
{availabilityError}
+ {/if} + + {#if availabilitySaving} +
+ + Saving availability... +
+ {/if} + {#if !selectedMemberId}

Select a team member to view the calendar.

{:else if loadingIndividual} @@ -144,6 +186,7 @@ capacity={individualCapacity} holidays={$holidaysStore} ptos={$ptosStore} + on:availabilitychange={handleAvailabilityChange} /> {/if} @@ -151,12 +194,16 @@ {:else if activeTab === 'holidays'} {:else} - + {/if}