chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import type { Allocation, WeekdayAvailability } from "@planarchy/shared";
|
||||
import { getAvailableHoursForDate } from "./calculator.js";
|
||||
|
||||
export interface AvailabilityConflict {
|
||||
date: Date;
|
||||
requestedHours: number;
|
||||
availableHours: number;
|
||||
existingHours: number;
|
||||
overageHours: number;
|
||||
}
|
||||
|
||||
export interface AvailabilityValidationResult {
|
||||
valid: boolean;
|
||||
conflicts: AvailabilityConflict[];
|
||||
totalConflictDays: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a new allocation does not exceed resource availability,
|
||||
* accounting for existing allocations in the same period.
|
||||
*
|
||||
* Pure function — no DB access. All data passed as parameters.
|
||||
*/
|
||||
export function validateAvailability(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
requestedHoursPerDay: number,
|
||||
availability: WeekdayAvailability,
|
||||
existingAllocations: Pick<Allocation, "startDate" | "endDate" | "hoursPerDay" | "status">[],
|
||||
includeSaturday?: boolean,
|
||||
): AvailabilityValidationResult {
|
||||
const effectiveAvailability: WeekdayAvailability = includeSaturday
|
||||
? availability
|
||||
: { ...availability, saturday: 0 };
|
||||
const conflicts: AvailabilityConflict[] = [];
|
||||
|
||||
const current = new Date(startDate);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const end = new Date(endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
const activeStatuses = new Set(["PROPOSED", "CONFIRMED", "ACTIVE"]);
|
||||
|
||||
while (current <= end) {
|
||||
const availableHours = getAvailableHoursForDate(current, effectiveAvailability);
|
||||
if (availableHours === 0) {
|
||||
// Non-working day — skip
|
||||
current.setDate(current.getDate() + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sum hours from existing (non-cancelled) allocations on this day
|
||||
const existingHoursOnDay = existingAllocations
|
||||
.filter((a) => {
|
||||
if (!activeStatuses.has(a.status)) return false;
|
||||
const aStart = new Date(a.startDate);
|
||||
aStart.setHours(0, 0, 0, 0);
|
||||
const aEnd = new Date(a.endDate);
|
||||
aEnd.setHours(0, 0, 0, 0);
|
||||
return current >= aStart && current <= aEnd;
|
||||
})
|
||||
.reduce((sum, a) => sum + a.hoursPerDay, 0);
|
||||
|
||||
const totalRequested = existingHoursOnDay + requestedHoursPerDay;
|
||||
|
||||
if (totalRequested > availableHours) {
|
||||
conflicts.push({
|
||||
date: new Date(current),
|
||||
requestedHours: requestedHoursPerDay,
|
||||
availableHours,
|
||||
existingHours: existingHoursOnDay,
|
||||
overageHours: totalRequested - availableHours,
|
||||
});
|
||||
}
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: conflicts.length === 0,
|
||||
conflicts,
|
||||
totalConflictDays: conflicts.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for allocation overlaps — same resource, overlapping date ranges.
|
||||
*/
|
||||
export function detectOverlaps(
|
||||
newStart: Date,
|
||||
newEnd: Date,
|
||||
existingAllocations: Pick<Allocation, "id" | "startDate" | "endDate" | "projectId" | "status">[],
|
||||
excludeProjectId?: string,
|
||||
): string[] {
|
||||
const activeStatuses = new Set(["PROPOSED", "CONFIRMED", "ACTIVE"]);
|
||||
const overlappingIds: string[] = [];
|
||||
|
||||
for (const alloc of existingAllocations) {
|
||||
if (!activeStatuses.has(alloc.status)) continue;
|
||||
if (excludeProjectId && alloc.projectId === excludeProjectId) continue;
|
||||
|
||||
const aStart = new Date(alloc.startDate);
|
||||
const aEnd = new Date(alloc.endDate);
|
||||
|
||||
// Ranges overlap if: newStart <= aEnd && newEnd >= aStart
|
||||
if (newStart <= aEnd && newEnd >= aStart) {
|
||||
overlappingIds.push(alloc.id);
|
||||
}
|
||||
}
|
||||
|
||||
return overlappingIds;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type {
|
||||
AllocationCalculationInput,
|
||||
AllocationCalculationResult,
|
||||
DailyBreakdown,
|
||||
WeekdayAvailability,
|
||||
} from "@planarchy/shared";
|
||||
import { getRecurringHoursForDay } from "./recurrence.js";
|
||||
|
||||
/** Day-of-week index → availability key */
|
||||
const DOW_KEYS: (keyof WeekdayAvailability)[] = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns the availability hours for a given date.
|
||||
* Returns 0 for days not in the availability map (treated as non-working).
|
||||
*/
|
||||
export function getAvailableHoursForDate(
|
||||
date: Date,
|
||||
availability: WeekdayAvailability,
|
||||
): number {
|
||||
const key = DOW_KEYS[date.getDay()];
|
||||
if (!key) return 0;
|
||||
return availability[key] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a given date is a working day for this resource.
|
||||
*/
|
||||
export function isWorkday(date: Date, availability: WeekdayAvailability): boolean {
|
||||
return getAvailableHoursForDate(date, availability) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts working days between startDate and endDate (inclusive).
|
||||
*/
|
||||
export function countWorkingDays(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
availability: WeekdayAvailability,
|
||||
): number {
|
||||
let count = 0;
|
||||
const current = new Date(startDate);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const end = new Date(endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
while (current <= end) {
|
||||
if (isWorkday(current, availability)) {
|
||||
count++;
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core allocation calculator: given hours/day, LCR, and date range,
|
||||
* computes total hours, total cost, and daily breakdown.
|
||||
*
|
||||
* Monetary values always in integer cents.
|
||||
*/
|
||||
export function calculateAllocation(input: AllocationCalculationInput): AllocationCalculationResult {
|
||||
const { lcrCents, hoursPerDay, startDate, endDate, availability, includeSaturday, recurrence, vacationDates } = input;
|
||||
|
||||
// When includeSaturday is not explicitly true, zero out saturday availability
|
||||
const effectiveAvailability: WeekdayAvailability = includeSaturday
|
||||
? availability
|
||||
: { ...availability, saturday: 0 };
|
||||
|
||||
// Pre-compute vacation date set (YYYY-MM-DD strings for O(1) lookup)
|
||||
const vacationDateSet = new Set<string>(
|
||||
(vacationDates ?? []).map((d) => {
|
||||
const copy = new Date(d);
|
||||
copy.setHours(0, 0, 0, 0);
|
||||
return copy.toISOString().split("T")[0]!;
|
||||
}),
|
||||
);
|
||||
|
||||
const allocationStart = new Date(startDate);
|
||||
allocationStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const breakdown: DailyBreakdown[] = [];
|
||||
const current = new Date(startDate);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const end = new Date(endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
let workingDays = 0;
|
||||
let totalHours = 0;
|
||||
|
||||
while (current <= end) {
|
||||
const dateKey = current.toISOString().split("T")[0]!;
|
||||
const isVacation = vacationDateSet.has(dateKey);
|
||||
|
||||
let effectiveHours: number;
|
||||
let dayIsWorkday: boolean;
|
||||
|
||||
if (isVacation) {
|
||||
// Vacation always blocks the day
|
||||
effectiveHours = 0;
|
||||
dayIsWorkday = false;
|
||||
} else if (recurrence) {
|
||||
// Recurrence pattern — may override hoursPerDay or skip the day entirely
|
||||
const recurHours = getRecurringHoursForDay(current, recurrence, hoursPerDay, allocationStart);
|
||||
if (recurHours === 0) {
|
||||
effectiveHours = 0;
|
||||
dayIsWorkday = false;
|
||||
} else {
|
||||
const availableHours = getAvailableHoursForDate(current, effectiveAvailability);
|
||||
dayIsWorkday = availableHours > 0;
|
||||
effectiveHours = dayIsWorkday ? Math.min(recurHours, availableHours) : 0;
|
||||
}
|
||||
} else {
|
||||
const availableHours = getAvailableHoursForDate(current, effectiveAvailability);
|
||||
dayIsWorkday = availableHours > 0;
|
||||
effectiveHours = dayIsWorkday ? Math.min(hoursPerDay, availableHours) : 0;
|
||||
}
|
||||
|
||||
// Cost = hours × lcrCents (already in cents-per-hour)
|
||||
const dayCostCents = Math.round(effectiveHours * lcrCents);
|
||||
|
||||
breakdown.push({
|
||||
date: new Date(current),
|
||||
isWorkday: dayIsWorkday,
|
||||
hours: effectiveHours,
|
||||
costCents: dayCostCents,
|
||||
});
|
||||
|
||||
if (dayIsWorkday) {
|
||||
workingDays++;
|
||||
totalHours += effectiveHours;
|
||||
}
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
const totalCostCents = breakdown.reduce((sum, d) => sum + d.costCents, 0);
|
||||
const dailyCostCents = Math.round(hoursPerDay * lcrCents);
|
||||
|
||||
return {
|
||||
workingDays,
|
||||
totalHours,
|
||||
totalCostCents,
|
||||
dailyCostCents,
|
||||
dailyBreakdown: breakdown,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates total allocation cost for a simple case (without full breakdown).
|
||||
* Useful for quick budget checks.
|
||||
*/
|
||||
export function calculateTotalCost(
|
||||
lcrCents: number,
|
||||
hoursPerDay: number,
|
||||
workingDays: number,
|
||||
): number {
|
||||
return Math.round(lcrCents * hoursPerDay * workingDays);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { WeekdayAvailability } from "@planarchy/shared";
|
||||
|
||||
export interface ChargeabilityAllocation {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
}
|
||||
|
||||
export interface ChargeabilityResult {
|
||||
availableHours: number;
|
||||
bookedHours: number;
|
||||
chargeability: number; // 0-100, rounded
|
||||
}
|
||||
|
||||
// Maps JS getDay() (0=Sun..6=Sat) to WeekdayAvailability keys
|
||||
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
|
||||
"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday",
|
||||
];
|
||||
|
||||
/** Count working hours a resource has available in [start, end] based on their schedule. */
|
||||
export function computeAvailableHours(
|
||||
availability: WeekdayAvailability,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): number {
|
||||
let hours = 0;
|
||||
const cur = new Date(start);
|
||||
cur.setHours(0, 0, 0, 0);
|
||||
const endNorm = new Date(end);
|
||||
endNorm.setHours(0, 0, 0, 0);
|
||||
while (cur <= endNorm) {
|
||||
const key = DAY_KEYS[cur.getDay()];
|
||||
hours += key ? (availability[key] ?? 0) : 0;
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
return hours;
|
||||
}
|
||||
|
||||
/** Count booked hours from allocations overlapping [start, end], working days only. */
|
||||
export function computeBookedHours(
|
||||
availability: WeekdayAvailability,
|
||||
allocations: ChargeabilityAllocation[],
|
||||
start: Date,
|
||||
end: Date,
|
||||
): number {
|
||||
let hours = 0;
|
||||
const startNorm = new Date(start); startNorm.setHours(0, 0, 0, 0);
|
||||
const endNorm = new Date(end); endNorm.setHours(0, 0, 0, 0);
|
||||
for (const alloc of allocations) {
|
||||
const aStart = new Date(alloc.startDate); aStart.setHours(0, 0, 0, 0);
|
||||
const aEnd = new Date(alloc.endDate); aEnd.setHours(0, 0, 0, 0);
|
||||
const overlapStart = aStart > startNorm ? aStart : startNorm;
|
||||
const overlapEnd = aEnd < endNorm ? aEnd : endNorm;
|
||||
if (overlapStart > overlapEnd) continue;
|
||||
const cur = new Date(overlapStart);
|
||||
while (cur <= overlapEnd) {
|
||||
const key = DAY_KEYS[cur.getDay()];
|
||||
if (key && (availability[key] ?? 0) > 0) {
|
||||
hours += alloc.hoursPerDay;
|
||||
}
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
}
|
||||
return hours;
|
||||
}
|
||||
|
||||
/** Compute chargeability metrics for a resource over a date range. */
|
||||
export function computeChargeability(
|
||||
availability: WeekdayAvailability,
|
||||
allocations: ChargeabilityAllocation[],
|
||||
start: Date,
|
||||
end: Date,
|
||||
): ChargeabilityResult {
|
||||
const availableHours = computeAvailableHours(availability, start, end);
|
||||
const bookedHours = computeBookedHours(availability, allocations, start, end);
|
||||
const chargeability = availableHours > 0
|
||||
? Math.min(100, Math.round((bookedHours / availableHours) * 100))
|
||||
: 0;
|
||||
return { availableHours, bookedHours, chargeability };
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./calculator.js";
|
||||
export * from "./availability-validator.js";
|
||||
export * from "./recurrence.js";
|
||||
export * from "./chargeability.js";
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { RecurrencePattern } from "@planarchy/shared";
|
||||
import { RecurrenceFrequency } from "@planarchy/shared";
|
||||
|
||||
/**
|
||||
* Returns the ISO week number of a date relative to a base date.
|
||||
* Used for biweekly parity checks.
|
||||
*/
|
||||
function weeksSince(base: Date, date: Date): number {
|
||||
const msPerWeek = 7 * 24 * 60 * 60 * 1000;
|
||||
const baseMonday = new Date(base);
|
||||
baseMonday.setHours(0, 0, 0, 0);
|
||||
// Normalize to start of the week containing base
|
||||
const baseDow = baseMonday.getDay();
|
||||
baseMonday.setDate(baseMonday.getDate() - baseDow);
|
||||
|
||||
const targetMonday = new Date(date);
|
||||
targetMonday.setHours(0, 0, 0, 0);
|
||||
const targetDow = targetMonday.getDay();
|
||||
targetMonday.setDate(targetMonday.getDate() - targetDow);
|
||||
|
||||
return Math.round((targetMonday.getTime() - baseMonday.getTime()) / msPerWeek);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a given date falls on a "recurring day" according to the pattern.
|
||||
*
|
||||
* @param date The calendar date to check
|
||||
* @param pattern Recurrence pattern from allocation metadata
|
||||
* @param allocationStartDate The allocation's own start date (used for biweekly parity)
|
||||
*/
|
||||
export function isRecurringDay(
|
||||
date: Date,
|
||||
pattern: RecurrencePattern,
|
||||
allocationStartDate: Date,
|
||||
): boolean {
|
||||
const dow = date.getDay(); // 0=Sun, 1=Mon … 6=Sat
|
||||
|
||||
switch (pattern.frequency) {
|
||||
case RecurrenceFrequency.WEEKLY: {
|
||||
const weekdays = pattern.weekdays ?? [];
|
||||
return weekdays.includes(dow);
|
||||
}
|
||||
|
||||
case RecurrenceFrequency.BIWEEKLY: {
|
||||
const weekdays = pattern.weekdays ?? [];
|
||||
if (!weekdays.includes(dow)) return false;
|
||||
// Check week parity: week 0 (same as allocationStart) = active,
|
||||
// odd weeks = skip, even weeks = active.
|
||||
const interval = pattern.interval ?? 2;
|
||||
const weeks = weeksSince(allocationStartDate, date);
|
||||
return weeks % interval === 0;
|
||||
}
|
||||
|
||||
case RecurrenceFrequency.MONTHLY: {
|
||||
const monthDay = pattern.monthDay;
|
||||
if (monthDay == null) return false;
|
||||
return date.getDate() === monthDay;
|
||||
}
|
||||
|
||||
case RecurrenceFrequency.CUSTOM:
|
||||
// CUSTOM means "always active, but with custom hoursPerDay"
|
||||
return true;
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective hours for a day based on the recurrence pattern.
|
||||
* Returns 0 when the day is not a recurring day.
|
||||
*
|
||||
* @param date The calendar date
|
||||
* @param pattern Recurrence pattern from allocation metadata
|
||||
* @param defaultHoursPerDay The allocation's base hoursPerDay
|
||||
* @param allocationStartDate The allocation's own start date
|
||||
*/
|
||||
export function getRecurringHoursForDay(
|
||||
date: Date,
|
||||
pattern: RecurrencePattern,
|
||||
defaultHoursPerDay: number,
|
||||
allocationStartDate: Date,
|
||||
): number {
|
||||
// Respect optional start/end overrides on the pattern itself
|
||||
if (pattern.startDate) {
|
||||
const ps = new Date(pattern.startDate);
|
||||
ps.setHours(0, 0, 0, 0);
|
||||
if (date < ps) return 0;
|
||||
}
|
||||
if (pattern.endDate) {
|
||||
const pe = new Date(pattern.endDate);
|
||||
pe.setHours(0, 0, 0, 0);
|
||||
if (date > pe) return 0;
|
||||
}
|
||||
|
||||
if (!isRecurringDay(date, pattern, allocationStartDate)) return 0;
|
||||
return pattern.hoursPerDay ?? defaultHoursPerDay;
|
||||
}
|
||||
Reference in New Issue
Block a user