fix(capacity): stabilize PTO flows and calendar consistency

Make PTO creation immediately approved, add PTO deletion, and ensure cache invalidation updates individual/team/revenue capacity consistently.

Harden holiday duplicate handling (422), support PTO-day availability overrides without disabling edits, and align tests plus OpenSpec artifacts with the new behavior.
This commit is contained in:
2026-02-19 22:47:39 -05:00
parent 0a9fdd248b
commit b821713cc7
21 changed files with 1081 additions and 128 deletions

View File

@@ -2,6 +2,16 @@ import { test, expect, type Page } from '@playwright/test';
const API_BASE = 'http://localhost:3000';
function unwrapData<T>(payload: unknown): T {
let current: unknown = payload;
while (current && typeof current === 'object' && 'data' in current) {
current = (current as { data: unknown }).data;
}
return current as T;
}
async function login(page: Page) {
await page.goto('/login');
await page.fill('input[type="email"]', 'superuser@headroom.test');
@@ -24,8 +34,28 @@ async function createTeamMember(page: Page, token: string) {
}
});
const body = await response.json();
return body.id as string;
const body = unwrapData<{ id: string }>(await response.json());
return body.id;
}
async function createPto(page: Page, token: string, memberId: string, date: string) {
await page.request.post(`${API_BASE}/api/ptos`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
data: {
team_member_id: memberId,
start_date: date,
end_date: date,
reason: 'Capacity calendar PTO test'
}
});
}
async function expectNoRawErrorAlerts(page: Page) {
await expect(page.locator('.alert.alert-error:has-text("<!DOCTYPE")')).toHaveCount(0);
await expect(page.locator('.alert.alert-error:has-text("SQLSTATE")')).toHaveCount(0);
}
test.describe('Capacity Calendar', () => {
@@ -54,6 +84,7 @@ test.describe('Capacity Calendar', () => {
test('should save availability change with success message', async ({ page }) => {
await expect(page.locator('select[aria-label="Select team member"]')).toBeVisible({ timeout: 10000 });
await expectNoRawErrorAlerts(page);
const teamMemberSelect = page.locator('select[aria-label="Select team member"]');
if (memberId) {
@@ -64,6 +95,7 @@ test.describe('Capacity Calendar', () => {
const availabilitySelects = page
.locator('select[aria-label^="Availability for"]')
.locator(':scope:not([disabled])')
.filter({ has: page.locator('option[value="1"]') });
await expect(availabilitySelects.first()).toBeVisible({ timeout: 5000 });
@@ -77,21 +109,57 @@ test.describe('Capacity Calendar', () => {
await expect(page.locator('text=Saving availability...')).toBeVisible({ timeout: 5000 });
await expect(page.locator('text=Saving availability...')).not.toBeVisible({ timeout: 10000 });
await expect(page.locator('[data-testid="capacity-calendar"] .alert.alert-error')).toHaveCount(0);
await expectNoRawErrorAlerts(page);
if (memberId) {
const period =
(await page.evaluate(() => localStorage.getItem('headroom_selected_period'))) ?? '2026-02';
const response = await page.request.get(
`${API_BASE}/api/capacity?month=${period}&team_member_id=${memberId}`
`${API_BASE}/api/capacity?month=${period}&team_member_id=${memberId}`,
{
headers: { Authorization: `Bearer ${authToken}` }
}
);
const body = await response.json();
const changedDetail = (body.details as Array<{ date: string; availability: number }>).find(
const body = unwrapData<{ details: Array<{ date: string; availability: number }> }>(
await response.json()
);
const changedDetail = body.details.find(
(detail) => detail.date === targetDate
);
expect(changedDetail).toBeDefined();
expect(changedDetail?.availability).toBe(0.5);
}
});
test('should preselect availability and force blocked days to zero', async ({ page }) => {
await expect(page.locator('select[aria-label="Select team member"]')).toBeVisible({ timeout: 10000 });
const teamMemberSelect = page.locator('select[aria-label="Select team member"]');
if (!memberId) {
test.fail(true, 'memberId was not created');
return;
}
await createPto(page, authToken, memberId, '2026-02-10');
await teamMemberSelect.selectOption({ value: memberId });
await expect(page.locator('[data-testid="capacity-calendar"]')).toBeVisible({ timeout: 10000 });
const weekdaySelect = page.locator('select[aria-label="Availability for 2026-02-02"]');
await expect(weekdaySelect).toBeVisible();
await expect(weekdaySelect).not.toHaveValue('');
const weekendSelect = page.locator('select[aria-label="Availability for 2026-02-01"]');
await expect(weekendSelect).toHaveValue('0');
await expect(weekendSelect).toBeDisabled();
await page.reload();
await page.waitForURL('/capacity');
await teamMemberSelect.selectOption({ value: memberId });
const ptoSelect = page.locator('select[aria-label="Availability for 2026-02-10"]');
await expect(ptoSelect).toHaveValue('0');
await expect(ptoSelect).toBeEnabled();
});
});