1df208dbcc
Allocation bars that have active optimistic overrides (post-drag, awaiting server confirmation) now pulse subtly via animate-pulse. The pending set is derived from the existing optimisticAllocations map keys, requiring no additional state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
483 lines
14 KiB
TypeScript
483 lines
14 KiB
TypeScript
import { VacationStatus } from "@capakraken/db";
|
|
import { getPublicHolidays, toIsoDate, MILLISECONDS_PER_DAY, DAY_KEYS, normalizeCityName, normalizeStateCode, type WeekdayAvailability } from "@capakraken/shared";
|
|
|
|
type CalendarScope = "COUNTRY" | "STATE" | "CITY";
|
|
|
|
type HolidayCalendarEntryRecord = {
|
|
date: Date;
|
|
isRecurringAnnual: boolean;
|
|
};
|
|
|
|
type HolidayCalendarRecord = {
|
|
entries: HolidayCalendarEntryRecord[];
|
|
};
|
|
|
|
type VacationRecord = {
|
|
resourceId: string;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
type: string;
|
|
isHalfDay: boolean;
|
|
};
|
|
|
|
export type ResourceCapacityProfile = {
|
|
id: string;
|
|
availability: WeekdayAvailability;
|
|
countryId: string | null | undefined;
|
|
countryCode: string | null | undefined;
|
|
federalState: string | null | undefined;
|
|
metroCityId: string | null | undefined;
|
|
metroCityName: string | null | undefined;
|
|
};
|
|
|
|
export type ResourceDailyAvailabilityContext = {
|
|
absenceFractionsByDate: Map<string, number>;
|
|
holidayDates: Set<string>;
|
|
vacationFractionsByDate: Map<string, number>;
|
|
};
|
|
|
|
type ResourceCapacityDbClient = {
|
|
holidayCalendar?: {
|
|
findMany: (args: {
|
|
where: Record<string, unknown>;
|
|
include: { entries: true };
|
|
orderBy: Array<Record<string, "asc" | "desc">>;
|
|
}) => Promise<unknown[]>;
|
|
};
|
|
vacation?: {
|
|
findMany: (args: {
|
|
where: Record<string, unknown>;
|
|
select: Record<string, boolean | Record<string, boolean>>;
|
|
}) => Promise<unknown[]>;
|
|
};
|
|
};
|
|
|
|
const CITY_HOLIDAY_RULES: Array<{
|
|
countryCode: string;
|
|
cityName: string;
|
|
resolveDates: (year: number) => string[];
|
|
}> = [
|
|
{
|
|
countryCode: "DE",
|
|
cityName: "Augsburg",
|
|
resolveDates: (year) => [`${year}-08-08`],
|
|
},
|
|
];
|
|
|
|
export function getAvailabilityHoursForDate(
|
|
availability: WeekdayAvailability,
|
|
date: Date,
|
|
): number {
|
|
const key = DAY_KEYS[date.getUTCDay()];
|
|
return key ? (availability[key] ?? 0) : 0;
|
|
}
|
|
|
|
function listBuiltinHolidayDates(input: {
|
|
periodStart: Date;
|
|
periodEnd: Date;
|
|
countryCode: string | null | undefined;
|
|
federalState: string | null | undefined;
|
|
metroCityName: string | null | undefined;
|
|
}): Set<string> {
|
|
const dates = new Set<string>();
|
|
const startIso = toIsoDate(input.periodStart);
|
|
const endIso = toIsoDate(input.periodEnd);
|
|
const startYear = input.periodStart.getUTCFullYear();
|
|
const endYear = input.periodEnd.getUTCFullYear();
|
|
|
|
if (input.countryCode === "DE") {
|
|
for (let year = startYear; year <= endYear; year += 1) {
|
|
for (const holiday of getPublicHolidays(year, input.federalState ?? undefined)) {
|
|
if (holiday.date >= startIso && holiday.date <= endIso) {
|
|
dates.add(holiday.date);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const normalizedCityName = normalizeCityName(input.metroCityName);
|
|
if (input.countryCode && normalizedCityName) {
|
|
for (const rule of CITY_HOLIDAY_RULES) {
|
|
if (
|
|
rule.countryCode === input.countryCode
|
|
&& normalizeCityName(rule.cityName) === normalizedCityName
|
|
) {
|
|
for (let year = startYear; year <= endYear; year += 1) {
|
|
for (const date of rule.resolveDates(year)) {
|
|
if (date >= startIso && date <= endIso) {
|
|
dates.add(date);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return dates;
|
|
}
|
|
|
|
function resolveCalendarEntryDates(
|
|
calendars: HolidayCalendarRecord[],
|
|
periodStart: Date,
|
|
periodEnd: Date,
|
|
): Set<string> {
|
|
const dates = new Set<string>();
|
|
const startIso = toIsoDate(periodStart);
|
|
const endIso = toIsoDate(periodEnd);
|
|
const startYear = periodStart.getUTCFullYear();
|
|
const endYear = periodEnd.getUTCFullYear();
|
|
|
|
for (const calendar of calendars) {
|
|
for (const entry of calendar.entries) {
|
|
const baseDate = new Date(entry.date);
|
|
for (let year = startYear; year <= endYear; year += 1) {
|
|
const effectiveDate = entry.isRecurringAnnual
|
|
? new Date(Date.UTC(year, baseDate.getUTCMonth(), baseDate.getUTCDate()))
|
|
: baseDate;
|
|
const isoDate = toIsoDate(effectiveDate);
|
|
if (isoDate >= startIso && isoDate <= endIso) {
|
|
dates.add(isoDate);
|
|
}
|
|
if (!entry.isRecurringAnnual) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return dates;
|
|
}
|
|
|
|
async function loadCustomHolidayDates(
|
|
db: ResourceCapacityDbClient,
|
|
input: {
|
|
periodStart: Date;
|
|
periodEnd: Date;
|
|
countryId: string | null | undefined;
|
|
federalState: string | null | undefined;
|
|
metroCityId: string | null | undefined;
|
|
},
|
|
): Promise<Set<string>> {
|
|
if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") {
|
|
return new Set();
|
|
}
|
|
|
|
const stateCode = normalizeStateCode(input.federalState);
|
|
const metroCityId = input.metroCityId?.trim() || null;
|
|
const calendars = await db.holidayCalendar.findMany({
|
|
where: {
|
|
isActive: true,
|
|
countryId: input.countryId,
|
|
OR: [
|
|
{ scopeType: "COUNTRY" as CalendarScope },
|
|
...(stateCode ? [{ scopeType: "STATE" as CalendarScope, stateCode }] : []),
|
|
...(metroCityId ? [{ scopeType: "CITY" as CalendarScope, metroCityId }] : []),
|
|
],
|
|
},
|
|
include: { entries: true },
|
|
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
|
|
});
|
|
|
|
return resolveCalendarEntryDates(
|
|
calendars as HolidayCalendarRecord[],
|
|
input.periodStart,
|
|
input.periodEnd,
|
|
);
|
|
}
|
|
|
|
function buildProfileKey(profile: ResourceCapacityProfile): string {
|
|
return JSON.stringify({
|
|
countryId: profile.countryId ?? null,
|
|
countryCode: profile.countryCode ?? null,
|
|
federalState: profile.federalState ?? null,
|
|
metroCityId: profile.metroCityId ?? null,
|
|
metroCityName: profile.metroCityName ?? null,
|
|
});
|
|
}
|
|
|
|
export async function loadResourceDailyAvailabilityContexts(
|
|
db: ResourceCapacityDbClient,
|
|
resources: ResourceCapacityProfile[],
|
|
periodStart: Date,
|
|
periodEnd: Date,
|
|
): Promise<Map<string, ResourceDailyAvailabilityContext>> {
|
|
const profileHolidayCache = new Map<string, Promise<Set<string>>>();
|
|
const resourceIds = resources.map((resource) => resource.id);
|
|
|
|
const vacations = resourceIds.length > 0 && typeof db.vacation?.findMany === "function"
|
|
? await db.vacation.findMany({
|
|
where: {
|
|
resourceId: { in: resourceIds },
|
|
status: VacationStatus.APPROVED,
|
|
startDate: { lte: periodEnd },
|
|
endDate: { gte: periodStart },
|
|
},
|
|
select: {
|
|
resourceId: true,
|
|
startDate: true,
|
|
endDate: true,
|
|
type: true,
|
|
isHalfDay: true,
|
|
},
|
|
})
|
|
: [];
|
|
|
|
const vacationsByResourceId = new Map<string, VacationRecord[]>();
|
|
for (const vacation of vacations as VacationRecord[]) {
|
|
const items = vacationsByResourceId.get(vacation.resourceId) ?? [];
|
|
items.push(vacation);
|
|
vacationsByResourceId.set(vacation.resourceId, items);
|
|
}
|
|
|
|
const contexts = new Map<string, ResourceDailyAvailabilityContext>();
|
|
|
|
for (const resource of resources) {
|
|
const profileKey = buildProfileKey(resource);
|
|
const holidayPromise = profileHolidayCache.get(profileKey)
|
|
?? (async () => {
|
|
const builtin = listBuiltinHolidayDates({
|
|
periodStart,
|
|
periodEnd,
|
|
countryCode: resource.countryCode,
|
|
federalState: resource.federalState,
|
|
metroCityName: resource.metroCityName,
|
|
});
|
|
const custom = await loadCustomHolidayDates(db, {
|
|
periodStart,
|
|
periodEnd,
|
|
countryId: resource.countryId,
|
|
federalState: resource.federalState,
|
|
metroCityId: resource.metroCityId,
|
|
});
|
|
return new Set([...builtin, ...custom]);
|
|
})();
|
|
|
|
if (!profileHolidayCache.has(profileKey)) {
|
|
profileHolidayCache.set(profileKey, holidayPromise);
|
|
}
|
|
|
|
const holidayDates = new Set(await holidayPromise);
|
|
const absenceFractionsByDate = new Map<string, number>();
|
|
const vacationFractionsByDate = new Map<string, number>();
|
|
const resourceVacations = vacationsByResourceId.get(resource.id) ?? [];
|
|
|
|
for (const vacation of resourceVacations) {
|
|
const overlapStart = new Date(Math.max(vacation.startDate.getTime(), periodStart.getTime()));
|
|
const overlapEnd = new Date(Math.min(vacation.endDate.getTime(), periodEnd.getTime()));
|
|
if (overlapStart > overlapEnd) {
|
|
continue;
|
|
}
|
|
|
|
const cursor = new Date(overlapStart);
|
|
cursor.setUTCHours(0, 0, 0, 0);
|
|
const end = new Date(overlapEnd);
|
|
end.setUTCHours(0, 0, 0, 0);
|
|
|
|
while (cursor <= end) {
|
|
const isoDate = toIsoDate(cursor);
|
|
const fraction = vacation.isHalfDay ? 0.5 : 1;
|
|
|
|
if (vacation.type === "PUBLIC_HOLIDAY") {
|
|
holidayDates.add(isoDate);
|
|
}
|
|
|
|
if (vacation.type !== "PUBLIC_HOLIDAY") {
|
|
const existingVacation = vacationFractionsByDate.get(isoDate) ?? 0;
|
|
vacationFractionsByDate.set(isoDate, Math.max(existingVacation, fraction));
|
|
}
|
|
|
|
const existing = absenceFractionsByDate.get(isoDate) ?? 0;
|
|
if (vacation.type === "PUBLIC_HOLIDAY" || !holidayDates.has(isoDate)) {
|
|
absenceFractionsByDate.set(isoDate, Math.max(existing, fraction));
|
|
}
|
|
|
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
}
|
|
}
|
|
|
|
for (const isoDate of holidayDates) {
|
|
const existing = absenceFractionsByDate.get(isoDate) ?? 0;
|
|
absenceFractionsByDate.set(isoDate, Math.max(existing, 1));
|
|
}
|
|
|
|
contexts.set(resource.id, {
|
|
absenceFractionsByDate,
|
|
holidayDates,
|
|
vacationFractionsByDate,
|
|
});
|
|
}
|
|
|
|
return contexts;
|
|
}
|
|
|
|
function calculateDayAvailabilityFraction(
|
|
context: ResourceDailyAvailabilityContext | undefined,
|
|
isoDate: string,
|
|
): number {
|
|
const fraction = context?.absenceFractionsByDate.get(isoDate) ?? 0;
|
|
return Math.max(0, 1 - fraction);
|
|
}
|
|
|
|
export function calculateEffectiveDayAvailability(input: {
|
|
availability: WeekdayAvailability;
|
|
date: Date;
|
|
context: ResourceDailyAvailabilityContext | undefined;
|
|
}): number {
|
|
const baseHours = getAvailabilityHoursForDate(input.availability, input.date);
|
|
if (baseHours <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
return baseHours * calculateDayAvailabilityFraction(input.context, toIsoDate(input.date));
|
|
}
|
|
|
|
export function calculateEffectiveAvailableHours(input: {
|
|
availability: WeekdayAvailability;
|
|
periodStart: Date;
|
|
periodEnd: Date;
|
|
context: ResourceDailyAvailabilityContext | undefined;
|
|
}): number {
|
|
let hours = 0;
|
|
const cursor = new Date(input.periodStart);
|
|
cursor.setUTCHours(0, 0, 0, 0);
|
|
const end = new Date(input.periodEnd);
|
|
end.setUTCHours(0, 0, 0, 0);
|
|
|
|
while (cursor <= end) {
|
|
hours += calculateEffectiveDayAvailability({
|
|
availability: input.availability,
|
|
date: cursor,
|
|
context: input.context,
|
|
});
|
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
}
|
|
|
|
return hours;
|
|
}
|
|
|
|
export function countEffectiveWorkingDays(input: {
|
|
availability: WeekdayAvailability;
|
|
periodStart: Date;
|
|
periodEnd: Date;
|
|
context: ResourceDailyAvailabilityContext | undefined;
|
|
}): number {
|
|
let days = 0;
|
|
const cursor = new Date(input.periodStart);
|
|
cursor.setUTCHours(0, 0, 0, 0);
|
|
const end = new Date(input.periodEnd);
|
|
end.setUTCHours(0, 0, 0, 0);
|
|
|
|
while (cursor <= end) {
|
|
if (calculateEffectiveDayAvailability({
|
|
availability: input.availability,
|
|
date: cursor,
|
|
context: input.context,
|
|
}) > 0) {
|
|
days += 1;
|
|
}
|
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
}
|
|
|
|
return days;
|
|
}
|
|
|
|
export function calculateEffectiveBookedHours(input: {
|
|
availability: WeekdayAvailability;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
hoursPerDay: number;
|
|
periodStart: Date;
|
|
periodEnd: Date;
|
|
context: ResourceDailyAvailabilityContext | undefined;
|
|
}): number {
|
|
const overlapStart = new Date(Math.max(input.startDate.getTime(), input.periodStart.getTime()));
|
|
const overlapEnd = new Date(Math.min(input.endDate.getTime(), input.periodEnd.getTime()));
|
|
|
|
if (overlapStart > overlapEnd) {
|
|
return 0;
|
|
}
|
|
|
|
let hours = 0;
|
|
const cursor = new Date(overlapStart);
|
|
cursor.setUTCHours(0, 0, 0, 0);
|
|
const end = new Date(overlapEnd);
|
|
end.setUTCHours(0, 0, 0, 0);
|
|
|
|
while (cursor <= end) {
|
|
const dayBaseHours = getAvailabilityHoursForDate(input.availability, cursor);
|
|
if (dayBaseHours > 0) {
|
|
hours += input.hoursPerDay * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
|
|
}
|
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
}
|
|
|
|
return hours;
|
|
}
|
|
|
|
export function calculateEffectiveAllocationHours(input: {
|
|
availability: WeekdayAvailability;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
hoursPerDay: number;
|
|
periodStart: Date;
|
|
periodEnd: Date;
|
|
context: ResourceDailyAvailabilityContext | undefined;
|
|
}): number {
|
|
return calculateEffectiveBookedHours(input);
|
|
}
|
|
|
|
export function calculateEffectiveAllocationCostCents(input: {
|
|
availability: WeekdayAvailability;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
dailyCostCents: number;
|
|
periodStart: Date;
|
|
periodEnd: Date;
|
|
context: ResourceDailyAvailabilityContext | undefined;
|
|
}): number {
|
|
let costCents = 0;
|
|
const overlapStart = new Date(
|
|
Math.max(input.startDate.getTime(), input.periodStart.getTime()),
|
|
);
|
|
const overlapEnd = new Date(
|
|
Math.min(input.endDate.getTime(), input.periodEnd.getTime()),
|
|
);
|
|
|
|
if (overlapStart > overlapEnd) {
|
|
return 0;
|
|
}
|
|
|
|
const cursor = new Date(overlapStart);
|
|
cursor.setUTCHours(0, 0, 0, 0);
|
|
const end = new Date(overlapEnd);
|
|
end.setUTCHours(0, 0, 0, 0);
|
|
|
|
while (cursor <= end) {
|
|
const baseHours = getAvailabilityHoursForDate(input.availability, cursor);
|
|
if (baseHours > 0) {
|
|
costCents += input.dailyCostCents * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
|
|
}
|
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
}
|
|
|
|
return Math.round(costCents);
|
|
}
|
|
|
|
export function enumerateIsoDates(
|
|
periodStart: Date,
|
|
periodEnd: Date,
|
|
): string[] {
|
|
const dates: string[] = [];
|
|
const cursor = new Date(periodStart);
|
|
cursor.setUTCHours(0, 0, 0, 0);
|
|
const end = new Date(periodEnd);
|
|
end.setUTCHours(0, 0, 0, 0);
|
|
|
|
while (cursor <= end) {
|
|
dates.push(toIsoDate(cursor));
|
|
cursor.setTime(cursor.getTime() + MILLISECONDS_PER_DAY);
|
|
}
|
|
|
|
return dates;
|
|
}
|