fix(capacity): Fix three defects - caching, save, filters

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
This commit is contained in:
2026-02-19 18:09:16 -05:00
parent c3ba83d101
commit d6b7215f93
9 changed files with 211 additions and 19 deletions

View File

@@ -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);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources;
use App\Models\TeamMemberAvailability;
class TeamMemberAvailabilityResource extends BaseResource
{
public function toArray($request): array
{
/** @var TeamMemberAvailability $availability */
$availability = $this->resource;
return [
'team_member_id' => $availability->team_member_id,
'date' => $availability->date?->toDateString(),
'availability' => (float) $availability->availability,
];
}
}

View File

@@ -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.
*/

View File

@@ -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']);

View File

@@ -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);

View File

@@ -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<PTO> {
export async function approvePTO(id: string): Promise<PTO> {
return api.put<PTO>(`/ptos/${id}/approve`);
}
export interface SaveAvailabilityPayload {
team_member_id: string;
date: string;
availability: 0 | 0.5 | 1;
}
export async function saveAvailability(
data: SaveAvailabilityPayload
): Promise<TeamMemberAvailability> {
return api.post<TeamMemberAvailability>('/capacity/availability', data);
}

View File

@@ -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;

View File

@@ -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<TeamMember[]>([]);
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();

View File

@@ -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}
<option value={member.id}>{member.name}</option>
{/each}
</select>
@@ -131,6 +162,17 @@
<div class="alert alert-error text-sm">{calendarError}</div>
{/if}
{#if availabilityError}
<div class="alert alert-error text-sm">{availabilityError}</div>
{/if}
{#if availabilitySaving}
<div class="flex items-center gap-2 text-sm text-base-content/60">
<span class="loading loading-spinner loading-xs"></span>
Saving availability...
</div>
{/if}
{#if !selectedMemberId}
<p class="text-sm text-base-content/60">Select a team member to view the calendar.</p>
{:else if loadingIndividual}
@@ -144,6 +186,7 @@
capacity={individualCapacity}
holidays={$holidaysStore}
ptos={$ptosStore}
on:availabilitychange={handleAvailabilityChange}
/>
{/if}
</div>
@@ -151,12 +194,16 @@
<CapacitySummary
teamCapacity={$teamCapacityStore}
revenue={$revenueStore}
{teamMembers}
teamMembers={$teamMembersStore}
/>
{:else if activeTab === 'holidays'}
<HolidayManager month={$selectedPeriod} holidays={$holidaysStore} />
{:else}
<PTOManager {teamMembers} month={$selectedPeriod} />
<PTOManager
teamMembers={$teamMembersStore}
month={$selectedPeriod}
bind:selectedMemberId={selectedMemberId}
/>
{/if}
</div>
</section>