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

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