chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -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 };
}
+4
View File
@@ -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;
}