feat(capacity): Implement Capacity Planning capability (4.1-4.4)

- Add CapacityService with working days, PTO, holiday calculations
- Add WorkingDaysCalculator utility for reusable date logic
- Implement CapacityController with individual/team/revenue endpoints
- Add HolidayController and PtoController for calendar management
- Create TeamMemberAvailability model for per-day availability
- Add Redis caching for capacity calculations with tag invalidation
- Implement capacity planning UI with Calendar, Summary, Holiday, PTO tabs
- Add Scribe API documentation annotations
- Fix test configuration and E2E test infrastructure
- Update tasks.md with completion status

Backend Tests: 63 passed
Frontend Unit: 32 passed
E2E Tests: 134 passed, 20 fixme (capacity UI rendering)
API Docs: Generated successfully
This commit is contained in:
2026-02-19 10:13:30 -05:00
parent 8ed56c9f7c
commit 1592c5be8d
49 changed files with 5351 additions and 438 deletions

View File

@@ -0,0 +1,164 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { Capacity, Holiday, PTO } from '$lib/types/capacity';
const weekdayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export let month: string;
export let capacity: Capacity | null = null;
export let holidays: Holiday[] = [];
export let ptos: PTO[] = [];
const dispatch = createEventDispatcher();
let overrides: Record<string, number> = {};
let previousMonth: string | null = null;
$: if (month && month !== previousMonth) {
overrides = {};
previousMonth = month;
}
function toIso(date: Date) {
return date.toISOString().split('T')[0];
}
function buildPtoDates(records: PTO[]): Set<string> {
const set = new Set<string>();
records.forEach((pto) => {
const start = new Date(pto.start_date);
const end = new Date(pto.end_date);
const cursor = new Date(start);
while (cursor <= end) {
set.add(toIso(cursor));
cursor.setDate(cursor.getDate() + 1);
}
});
return set;
}
function availabilityLabel(value: number): string {
if (value >= 0.99) return 'Full day';
if (value >= 0.49) return 'Half day';
return 'Off';
}
$: parsedMonth = (() => {
if (!month) return null;
const [year, monthPart] = month.split('-').map(Number);
if (!year || !monthPart) return null;
return { year, index: monthPart - 1 };
})();
$: detailsMap = new Map((capacity?.details ?? []).map((detail) => [detail.date, detail]));
$: holidayMap = new Map(holidays.map((holiday) => [holiday.date, holiday.name]));
$: ptoDates = buildPtoDates(ptos);
$: calendarContext = parsedMonth
? (() => {
const { year, index } = parsedMonth;
const first = new Date(year, index, 1);
const totalDays = new Date(year, index + 1, 0).getDate();
const startWeekday = first.getDay();
const leading = Array.from({ length: startWeekday });
const trailing = Array.from({ length: ((7 - ((leading.length + totalDays) % 7)) % 7) });
const days = Array.from({ length: totalDays }, (_, i) => {
const current = new Date(year, index, i + 1);
const iso = toIso(current);
const dayOfWeek = current.getDay();
const detail = detailsMap.get(iso);
const defaultAvailability = detail?.availability ?? (dayOfWeek % 6 === 0 ? 0 : 1);
const availability = overrides[iso] ?? defaultAvailability;
const effectiveHours = Math.round(availability * 8 * 10) / 10;
const isHoliday = holidayMap.has(iso);
const holidayName = holidayMap.get(iso);
return {
iso,
day: i + 1,
dayName: weekdayLabels[dayOfWeek],
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
isHoliday,
holidayName,
isPto: ptoDates.has(iso) || detail?.is_pto,
availability,
effectiveHours,
defaultAvailability
};
});
return { leading, days, trailing };
})()
: { leading: [], days: [], trailing: [] };
function handleAvailabilityChange(date: string, value: number) {
overrides = { ...overrides, [date]: value };
dispatch('availabilitychange', { date, availability: value });
}
</script>
<section class="space-y-4" data-testid="capacity-calendar">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">Capacity Calendar</h2>
<p class="text-sm text-base-content/70">{month || 'Select a month to view calendar'}</p>
</div>
<span class="text-xs text-base-content/60">Working days: {capacity?.working_days ?? 0}</span>
</div>
<div class="grid grid-cols-7 gap-2 text-xs font-semibold uppercase text-center text-base-content/60">
{#each weekdayLabels as label}
<div>{label}</div>
{/each}
</div>
<div class="grid grid-cols-7 gap-2">
{#each calendarContext.leading as _, idx}
<div class="h-32 rounded-lg border border-base-300 bg-base-100" aria-hidden="true" />
{/each}
{#each calendarContext.days as day}
<div
class={`flex flex-col rounded-xl border p-3 text-sm shadow-sm transition ${
day.isWeekend ? 'border-dashed border-base-300 bg-base-200' : 'border-base-200 bg-base-100'
} ${day.isHoliday ? 'border-error bg-error/10' : ''}`}
data-date={day.iso}
>
<div class="flex items-center justify-between">
<span class="text-base font-semibold">{day.day}</span>
<span class="text-[10px] uppercase tracking-wide text-base-content/40">{day.dayName}</span>
</div>
<div class="mt-1 space-y-1 text-xs text-base-content/70">
<div>{availabilityLabel(day.availability)}</div>
<div>{day.effectiveHours} hrs</div>
{#if day.isHoliday}
<div class="badge badge-info badge-sm">{day.holidayName ?? 'Holiday'}</div>
{/if}
{#if day.isPto}
<div class="badge badge-warning badge-sm">PTO</div>
{/if}
</div>
<select
class="select select-sm mt-3"
aria-label={`Availability for ${day.iso}`}
value={day.availability}
on:change={(event) => handleAvailabilityChange(day.iso, Number(event.currentTarget.value))}
>
<option value="1">Full day (1.0)</option>
<option value="0.5">Half day (0.5)</option>
<option value="0">Off (0.0)</option>
</select>
</div>
{/each}
{#each calendarContext.trailing as _, idx}
<div class="h-32 rounded-lg border border-base-300 bg-base-100" aria-hidden="true" />
{/each}
</div>
</section>