feat(planning): ship holiday-aware planning and assistant upgrades

This commit is contained in:
2026-03-28 22:49:28 +01:00
parent 2a005794e7
commit 4f48afe7b4
151 changed files with 17738 additions and 1940 deletions
+48 -11
View File
@@ -1,6 +1,11 @@
import { listAssignmentBookings } from "@capakraken/application";
import { rankResources } from "@capakraken/staffing";
import type { SkillEntry } from "@capakraken/shared";
import type { SkillEntry, WeekdayAvailability } from "@capakraken/shared";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "./resource-capacity.js";
import { createNotificationsForUsers } from "./create-notification.js";
/**
@@ -58,6 +63,11 @@ type DbClient = Parameters<typeof listAssignmentBookings>[0] & {
chargeabilityTarget: number;
availability: unknown;
valueScore: number | null;
countryId: string | null;
federalState: string | null;
metroCityId: string | null;
country: { code: string | null } | null;
metroCity: { name: string | null } | null;
}>>;
};
notification: {
@@ -154,27 +164,54 @@ export async function generateAutoSuggestions(
endDate: demand.endDate,
resourceIds: resources.map((r) => r.id),
});
const contexts = await loadResourceDailyAvailabilityContexts(
db as Parameters<typeof loadResourceDailyAvailabilityContexts>[0],
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
demand.startDate,
demand.endDate,
);
// 5. Enrich resources with utilization data for the demand's date range
const enrichedResources = resources.map((resource) => {
const avail = resource.availability as
| { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number }
| null;
const totalAvailableHours = avail?.monday ?? 8;
const availability = resource.availability as unknown as WeekdayAvailability;
const context = contexts.get(resource.id);
const resourceBookings = bookings.filter((b) => b.resourceId === resource.id);
const allocatedHoursPerDay = resourceBookings.reduce(
(sum, b) => sum + b.hoursPerDay,
const totalAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart: demand.startDate,
periodEnd: demand.endDate,
context,
});
const allocatedHours = resourceBookings.reduce(
(sum, booking) =>
sum + calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart: demand.startDate,
periodEnd: demand.endDate,
context,
}),
0,
);
const utilizationPercent =
totalAvailableHours > 0
? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100)
? Math.min(100, (allocatedHours / totalAvailableHours) * 100)
: 0;
const wouldExceedCapacity =
allocatedHoursPerDay + demand.hoursPerDay > totalAvailableHours;
const wouldExceedCapacity = totalAvailableHours > 0
? allocatedHours + demand.hoursPerDay > totalAvailableHours
: demand.hoursPerDay > 0;
return {
id: resource.id,
+58 -67
View File
@@ -1,14 +1,16 @@
import {
deriveResourceForecast,
getMonthRange,
countWorkingDaysInOverlap,
calculateSAH,
type AssignmentSlice,
} from "@capakraken/engine";
import type { SpainScheduleRule } from "@capakraken/shared";
import type { WeekdayAvailability } from "@capakraken/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
import { VacationStatus } from "@capakraken/db";
import { createNotificationsForUsers } from "./create-notification.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "./resource-capacity.js";
/**
* Minimal DB client type for chargeability alerts.
@@ -24,23 +26,19 @@ type DbClient = {
id: string;
displayName: string;
fte: number;
availability: unknown;
countryId: string | null;
metroCityId: string | null;
federalState: string | null;
chargeabilityTarget: number;
country: { dailyWorkingHours: number | null; scheduleRules: unknown } | null;
country: {
id?: string | null;
code: string | null;
dailyWorkingHours: number | null;
scheduleRules: unknown;
} | null;
managementLevelGroup: { targetPercentage: number | null } | null;
}>
>;
};
vacation: {
findMany: (args: {
where: Record<string, unknown>;
select: Record<string, unknown>;
}) => Promise<
Array<{
resourceId: string;
startDate: Date;
endDate: Date;
type: string;
isHalfDay: boolean;
metroCity: { id?: string | null; name: string | null } | null;
}>
>;
};
@@ -105,9 +103,14 @@ export async function checkChargeabilityAlerts(
id: true,
displayName: true,
fte: true,
availability: true,
countryId: true,
metroCityId: true,
federalState: true,
chargeabilityTarget: true,
country: { select: { dailyWorkingHours: true, scheduleRules: true } },
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
managementLevelGroup: { select: { targetPercentage: true } },
metroCity: { select: { id: true, name: true } },
},
});
@@ -121,56 +124,32 @@ export async function checkChargeabilityAlerts(
endDate: monthEnd,
resourceIds,
});
// Fetch vacations for the current month
const vacations = await (db as DbClient).vacation.findMany({
where: {
resourceId: { in: resourceIds },
status: VacationStatus.APPROVED,
startDate: { lte: monthEnd },
endDate: { gte: monthStart },
},
select: {
resourceId: true,
startDate: true,
endDate: true,
type: true,
isHalfDay: true,
},
});
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
db as Parameters<typeof loadResourceDailyAvailabilityContexts>[0],
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
monthStart,
monthEnd,
);
// Compute chargeability per resource
const underperformers: Array<{ resource: typeof resources[0]; chg: number; target: number; gap: number }> = [];
for (const resource of resources) {
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
// Compute absence dates for SAH
const resourceVacations = vacations.filter((v) => v.resourceId === resource.id);
const absenceDates: string[] = [];
for (const v of resourceVacations) {
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
if (vStart > vEnd) continue;
const cursor = new Date(vStart);
cursor.setUTCHours(0, 0, 0, 0);
const endNorm = new Date(vEnd);
endNorm.setUTCHours(0, 0, 0, 0);
while (cursor <= endNorm) {
absenceDates.push(cursor.toISOString().slice(0, 10));
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
const scheduleRules = (resource.country?.scheduleRules ?? null) as SpainScheduleRule | null;
const sahResult = calculateSAH({
dailyWorkingHours: dailyHours,
scheduleRules,
fte: resource.fte,
const availability = resource.availability as unknown as WeekdayAvailability;
const context = availabilityContexts.get(resource.id);
const availableHours = calculateEffectiveAvailableHours({
availability,
periodStart: monthStart,
periodEnd: monthEnd,
publicHolidays: [],
absenceDays: absenceDates,
context,
});
// Build assignment slices
@@ -178,12 +157,24 @@ export async function checkChargeabilityAlerts(
(b) => b.resourceId === resource.id && isChargeabilityActualBooking(b, false),
);
const slices: AssignmentSlice[] = resourceBookings.map((b) => {
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, b.startDate, b.endDate);
const slices: AssignmentSlice[] = resourceBookings.flatMap((b) => {
const totalChargeableHours = calculateEffectiveBookedHours({
availability,
startDate: b.startDate,
endDate: b.endDate,
hoursPerDay: b.hoursPerDay,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
if (totalChargeableHours <= 0) {
return [];
}
return {
hoursPerDay: b.hoursPerDay,
workingDays,
workingDays: 0,
categoryCode: "Chg", // simplified — treat all actual bookings as chargeable
totalChargeableHours,
};
});
@@ -194,7 +185,7 @@ export async function checkChargeabilityAlerts(
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: sahResult.standardAvailableHours,
sah: availableHours,
});
const chgPct = forecast.chg * 100;
+53 -21
View File
@@ -9,7 +9,7 @@
* Duplicate-safe: skips holidays that already exist (by date + type + resourceId).
*/
import { getPublicHolidays } from "@capakraken/shared";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "./holiday-availability.js";
interface MinimalVacation {
resourceId: string;
@@ -19,14 +19,20 @@ interface MinimalVacation {
interface AutoImportDb {
resource: {
findMany: (args: {
where: { isActive: boolean };
select: { id: string; federalState: string };
}) => Promise<Array<{ id: string; federalState: string | null }>>;
findMany: (args: any) => any;
};
country?: {
findUnique: (args: any) => any;
};
metroCity?: {
findUnique: (args: any) => any;
};
holidayCalendar?: {
findMany: (args: any) => any;
};
vacation: {
findMany: (args: unknown) => Promise<MinimalVacation[]>;
createMany: (args: { data: unknown[]; skipDuplicates?: boolean }) => Promise<{ count: number }>;
findMany: (args: any) => any;
createMany: (args: any) => any;
};
}
@@ -42,34 +48,60 @@ export interface AutoImportResult {
* Returns the number of holiday vacation records created.
*/
export async function autoImportPublicHolidays(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
db: any,
db: AutoImportDb,
year: number,
): Promise<AutoImportResult> {
const resources: Array<{ id: string; federalState: string | null }> = await db.resource.findMany({
const resources = await db.resource.findMany({
where: { isActive: true },
select: { id: true, federalState: true },
select: {
id: true,
federalState: true,
countryId: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
});
if (resources.length === 0) {
return { year, holidaysCreated: 0, resourcesProcessed: 0, skippedExisting: 0 };
}
// Group resources by federal state (null = federal-only holidays)
const byState = new Map<string | null, string[]>();
const nextYearStart = new Date(`${year}-01-01T00:00:00.000Z`);
const nextYearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
const byHolidayProfile = new Map<string, typeof resources>();
for (const resource of resources) {
const state = resource.federalState ?? null;
const group = byState.get(state) ?? [];
group.push(resource.id);
byState.set(state, group);
const profileKey = JSON.stringify({
countryCode: resource.country?.code ?? null,
federalState: resource.federalState ?? null,
metroCityName: resource.metroCity?.name ?? null,
});
const group = byHolidayProfile.get(profileKey) ?? [];
group.push(resource);
byHolidayProfile.set(profileKey, group);
}
let totalCreated = 0;
let totalSkipped = 0;
for (const [state, resourceIds] of byState) {
const holidays = getPublicHolidays(year, state ?? undefined);
for (const [, groupedResources] of byHolidayProfile) {
const sample = groupedResources[0];
if (!sample) {
continue;
}
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), {
periodStart: nextYearStart,
periodEnd: nextYearEnd,
countryId: sample.countryId,
countryCode: sample.country?.code ?? null,
federalState: sample.federalState,
metroCityId: sample.metroCityId,
metroCityName: sample.metroCity?.name ?? null,
});
if (holidays.length === 0) continue;
const resourceIds = groupedResources.map((resource: { id: string }) => resource.id);
for (const holiday of holidays) {
const holidayDate = new Date(holiday.date);
@@ -86,13 +118,13 @@ export async function autoImportPublicHolidays(
});
const existingResourceIds = new Set(existing.map((v: MinimalVacation) => v.resourceId));
const newResourceIds = resourceIds.filter((id) => !existingResourceIds.has(id));
const newResourceIds = resourceIds.filter((id: string) => !existingResourceIds.has(id));
totalSkipped += existingResourceIds.size;
if (newResourceIds.length === 0) continue;
const records = newResourceIds.map((resourceId) => ({
const records = newResourceIds.map((resourceId: string) => ({
resourceId,
type: "PUBLIC_HOLIDAY",
status: "APPROVED",
@@ -0,0 +1,464 @@
import { getPublicHolidays, type AbsenceDay } from "@capakraken/shared";
type VacationLike = {
startDate: Date;
endDate: Date;
type: string;
isHalfDay: boolean;
};
type HolidayAvailabilityInput = {
vacations: VacationLike[];
periodStart: Date;
periodEnd: Date;
countryCode?: string | null | undefined;
federalState?: string | null | undefined;
metroCityName?: string | null | undefined;
resolvedHolidayStrings?: string[] | undefined;
};
type HolidayAvailabilityResult = {
absenceDateStrings: string[];
publicHolidayStrings: string[];
absenceDays: AbsenceDay[];
};
export type CalendarHoliday = {
date: string;
name: string;
scope: "COUNTRY" | "STATE" | "CITY";
};
type CalendarScope = CalendarHoliday["scope"];
type HolidayCalendarEntryRecord = {
date: Date;
name: string;
isRecurringAnnual: boolean;
};
type HolidayCalendarRecord = {
id: string;
name: string;
scopeType: CalendarScope;
priority: number;
createdAt?: Date;
entries: HolidayCalendarEntryRecord[];
};
type HolidayResolverDb = {
[key: string]: unknown;
country?: {
findUnique: (args: any) => any;
};
metroCity?: {
findUnique: (args: any) => any;
};
holidayCalendar?: {
findMany: (args: any) => any;
};
};
type ResolvedHoliday = CalendarHoliday & {
calendarName: string;
priority: number;
sourceType: "BUILTIN" | "CUSTOM";
};
export function asHolidayResolverDb(db: unknown): HolidayResolverDb {
return db as HolidayResolverDb;
}
export function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
type CityHolidayRule = {
countryCode: string;
cityName: string;
resolveDates: (year: number) => string[];
};
const CITY_HOLIDAY_RULES: CityHolidayRule[] = [
{
countryCode: "DE",
cityName: "Augsburg",
resolveDates: (year) => [`${year}-08-08`],
},
];
const SCOPE_WEIGHT: Record<CalendarScope, number> = {
COUNTRY: 1,
STATE: 2,
CITY: 3,
};
function normalizeCityName(cityName?: string | null): string | null {
const normalized = cityName?.trim().toLowerCase();
return normalized && normalized.length > 0 ? normalized : null;
}
function normalizeStateCode(stateCode?: string | null): string | null {
const normalized = stateCode?.trim().toUpperCase();
return normalized && normalized.length > 0 ? normalized : null;
}
function resolveCalendarEntries(
calendars: HolidayCalendarRecord[],
periodStart: Date,
periodEnd: Date,
): ResolvedHoliday[] {
const startYear = periodStart.getUTCFullYear();
const endYear = periodEnd.getUTCFullYear();
const startIso = toIsoDate(periodStart);
const endIso = toIsoDate(periodEnd);
const resolved = new Map<string, ResolvedHoliday>();
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 key = toIsoDate(effectiveDate);
if (key < startIso || key > endIso) {
if (!entry.isRecurringAnnual) {
break;
}
continue;
}
const candidate: ResolvedHoliday = {
date: key,
name: entry.name,
scope: calendar.scopeType,
calendarName: calendar.name,
priority: calendar.priority,
sourceType: "CUSTOM",
};
const existing = resolved.get(key);
if (
!existing
|| SCOPE_WEIGHT[candidate.scope] > SCOPE_WEIGHT[existing.scope]
|| (
SCOPE_WEIGHT[candidate.scope] === SCOPE_WEIGHT[existing.scope]
&& candidate.priority > existing.priority
)
|| (
SCOPE_WEIGHT[candidate.scope] === SCOPE_WEIGHT[existing.scope]
&& candidate.priority === existing.priority
&& existing.sourceType === "BUILTIN"
)
) {
resolved.set(key, candidate);
}
if (!entry.isRecurringAnnual) {
break;
}
}
}
}
return [...resolved.values()].sort((left, right) => left.date.localeCompare(right.date));
}
function mergeResolvedHolidays(
builtInHolidays: CalendarHoliday[],
customHolidays: ResolvedHoliday[],
): ResolvedHoliday[] {
const merged = new Map<string, ResolvedHoliday>();
for (const holiday of builtInHolidays) {
merged.set(holiday.date, {
...holiday,
calendarName: "System",
priority: Number.MIN_SAFE_INTEGER,
sourceType: "BUILTIN",
});
}
for (const holiday of customHolidays) {
const existing = merged.get(holiday.date);
if (
!existing
|| SCOPE_WEIGHT[holiday.scope] > SCOPE_WEIGHT[existing.scope]
|| (
SCOPE_WEIGHT[holiday.scope] === SCOPE_WEIGHT[existing.scope]
&& holiday.priority >= existing.priority
)
) {
merged.set(holiday.date, holiday);
}
}
return [...merged.values()].sort((left, right) => left.date.localeCompare(right.date));
}
async function loadScopedHolidayCalendars(
db: HolidayResolverDb,
input: {
countryId?: string | null | undefined;
stateCode?: string | null | undefined;
metroCityId?: string | null | undefined;
},
): Promise<HolidayCalendarRecord[]> {
if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") {
return [];
}
const stateCode = normalizeStateCode(input.stateCode);
const metroCityId = input.metroCityId?.trim() || null;
return db.holidayCalendar.findMany({
where: {
isActive: true,
countryId: input.countryId,
OR: [
{ scopeType: "COUNTRY" },
...(stateCode ? [{ scopeType: "STATE" as const, stateCode }] : []),
...(metroCityId ? [{ scopeType: "CITY" as const, metroCityId }] : []),
],
},
include: { entries: true },
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
});
}
export function getCalendarHolidayStrings(
periodStart: Date,
periodEnd: Date,
countryCode?: string | null,
federalState?: string | null,
metroCityName?: string | null,
): string[] {
return getCalendarHolidays(
periodStart,
periodEnd,
countryCode,
federalState,
metroCityName,
).map((holiday) => holiday.date);
}
export function getCalendarHolidays(
periodStart: Date,
periodEnd: Date,
countryCode?: string | null,
federalState?: string | null,
metroCityName?: string | null,
): CalendarHoliday[] {
const startYear = periodStart.getUTCFullYear();
const endYear = periodEnd.getUTCFullYear();
const holidays = new Map<string, CalendarHoliday>();
if (countryCode === "DE") {
for (let year = startYear; year <= endYear; year += 1) {
for (const holiday of getPublicHolidays(year, federalState ?? undefined)) {
if (holiday.date >= toIsoDate(periodStart) && holiday.date <= toIsoDate(periodEnd)) {
holidays.set(holiday.date, {
date: holiday.date,
name: holiday.name,
scope: holiday.federal ? "COUNTRY" : "STATE",
});
}
}
}
}
const normalizedCityName = normalizeCityName(metroCityName);
if (countryCode && normalizedCityName) {
for (const rule of CITY_HOLIDAY_RULES) {
if (
rule.countryCode === countryCode
&& normalizeCityName(rule.cityName) === normalizedCityName
) {
for (let year = startYear; year <= endYear; year += 1) {
for (const holidayDate of rule.resolveDates(year)) {
if (holidayDate >= toIsoDate(periodStart) && holidayDate <= toIsoDate(periodEnd)) {
holidays.set(holidayDate, {
date: holidayDate,
name: "Augsburger Friedensfest",
scope: "CITY",
});
}
}
}
}
}
}
return [...holidays.values()].sort((left, right) => left.date.localeCompare(right.date));
}
export async function getResolvedCalendarHolidays(
db: HolidayResolverDb,
input: {
periodStart: Date;
periodEnd: Date;
countryId?: string | null | undefined;
countryCode?: string | null | undefined;
federalState?: string | null | undefined;
metroCityId?: string | null | undefined;
metroCityName?: string | null | undefined;
},
): Promise<ResolvedHoliday[]> {
let countryCode = input.countryCode ?? null;
if (!countryCode && input.countryId && typeof db.country?.findUnique === "function") {
const country = await db.country.findUnique({
where: { id: input.countryId },
select: { code: true },
});
countryCode = country?.code ?? null;
}
let metroCityName = input.metroCityName ?? null;
if (!metroCityName && input.metroCityId && typeof db.metroCity?.findUnique === "function") {
const metroCity = await db.metroCity.findUnique({
where: { id: input.metroCityId },
select: { name: true },
});
metroCityName = metroCity?.name ?? null;
}
const builtIn = getCalendarHolidays(
input.periodStart,
input.periodEnd,
countryCode,
input.federalState,
metroCityName,
);
const calendars = await loadScopedHolidayCalendars(db, {
countryId: input.countryId,
stateCode: input.federalState,
metroCityId: input.metroCityId,
});
const custom = resolveCalendarEntries(calendars, input.periodStart, input.periodEnd);
return mergeResolvedHolidays(builtIn, custom);
}
export async function getResolvedCalendarHolidayStrings(
db: HolidayResolverDb,
input: {
periodStart: Date;
periodEnd: Date;
countryId?: string | null | undefined;
countryCode?: string | null | undefined;
federalState?: string | null | undefined;
metroCityId?: string | null | undefined;
metroCityName?: string | null | undefined;
},
): Promise<string[]> {
const holidays = await getResolvedCalendarHolidays(db, input);
return holidays.map((holiday) => holiday.date);
}
export function collectHolidayAvailability(
input: HolidayAvailabilityInput,
): HolidayAvailabilityResult {
const periodStartIso = toIsoDate(input.periodStart);
const periodEndIso = toIsoDate(input.periodEnd);
const publicHolidaySet = new Set(
input.resolvedHolidayStrings
? input.resolvedHolidayStrings.filter((date) => date >= periodStartIso && date <= periodEndIso)
: getCalendarHolidayStrings(
input.periodStart,
input.periodEnd,
input.countryCode,
input.federalState,
input.metroCityName,
),
);
const absenceDateSet = new Set<string>();
const absenceDayMap = new Map<string, AbsenceDay>();
for (const isoDate of publicHolidaySet) {
absenceDayMap.set(isoDate, {
date: new Date(`${isoDate}T00:00:00.000Z`),
type: "PUBLIC_HOLIDAY",
});
}
for (const vacation of input.vacations) {
if (vacation.type !== "PUBLIC_HOLIDAY") {
continue;
}
const overlapStart = new Date(
Math.max(vacation.startDate.getTime(), input.periodStart.getTime()),
);
const overlapEnd = new Date(
Math.min(vacation.endDate.getTime(), input.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);
publicHolidaySet.add(isoDate);
absenceDayMap.set(isoDate, {
date: new Date(cursor),
type: "PUBLIC_HOLIDAY",
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
});
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
for (const vacation of input.vacations) {
if (vacation.type === "PUBLIC_HOLIDAY") {
continue;
}
const overlapStart = new Date(
Math.max(vacation.startDate.getTime(), input.periodStart.getTime()),
);
const overlapEnd = new Date(
Math.min(vacation.endDate.getTime(), input.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);
const triggerType = vacation.type === "SICK" ? "SICK" : "VACATION";
while (cursor <= end) {
const isoDate = toIsoDate(cursor);
if (!publicHolidaySet.has(isoDate)) {
absenceDateSet.add(isoDate);
absenceDayMap.set(isoDate, {
date: new Date(cursor),
type: triggerType,
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
});
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
return {
absenceDateStrings: [...absenceDateSet].sort(),
publicHolidayStrings: [...publicHolidaySet].sort(),
absenceDays: [...absenceDayMap.values()],
};
}
+13 -12
View File
@@ -3,23 +3,24 @@ import pino from "pino";
const isProduction = process.env["NODE_ENV"] === "production";
const LOG_LEVEL = process.env["LOG_LEVEL"] ?? "info";
const devDestination = pino.destination({ dest: 1, sync: true });
export const logger = pino({
level: LOG_LEVEL,
base: { service: "capakraken-api" },
...(isProduction
? {}
: {
transport: {
target: "pino/file",
options: { destination: 1 }, // stdout
},
export const logger = isProduction
? pino({
level: LOG_LEVEL,
base: { service: "capakraken-api" },
})
: pino(
{
level: LOG_LEVEL,
base: { service: "capakraken-api" },
formatters: {
level(label: string) {
return { level: label };
},
},
}),
});
},
devDestination,
);
export type Logger = typeof logger;
+439
View File
@@ -0,0 +1,439 @@
import { VacationStatus } from "@capakraken/db";
import { getPublicHolidays, 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 DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
const CITY_HOLIDAY_RULES: Array<{
countryCode: string;
cityName: string;
resolveDates: (year: number) => string[];
}> = [
{
countryCode: "DE",
cityName: "Augsburg",
resolveDates: (year) => [`${year}-08-08`],
},
];
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function normalizeCityName(cityName?: string | null): string | null {
const normalized = cityName?.trim().toLowerCase();
return normalized && normalized.length > 0 ? normalized : null;
}
function normalizeStateCode(stateCode?: string | null): string | null {
const normalized = stateCode?.trim().toUpperCase();
return normalized && normalized.length > 0 ? normalized : null;
}
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;
}
@@ -0,0 +1,102 @@
import { VacationStatus, VacationType } from "@capakraken/db";
import { getResolvedCalendarHolidayStrings, toIsoDate } from "./holiday-availability.js";
type ResourceHolidayContextDb = {
resource: {
findUnique: (args: any) => any;
};
country?: {
findUnique: (args: any) => any;
};
metroCity?: {
findUnique: (args: any) => any;
};
holidayCalendar?: {
findMany: (args: any) => any;
};
vacation: {
findMany: (args: any) => any;
};
};
export type ResourceHolidayContext = {
countryId?: string | null;
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityId?: string | null;
metroCityName?: string | null;
calendarHolidayStrings: string[];
publicHolidayStrings: string[];
};
function clampToDay(value: Date): Date {
const date = new Date(value);
date.setUTCHours(0, 0, 0, 0);
return date;
}
export async function loadResourceHolidayContext(
db: ResourceHolidayContextDb,
resourceId: string,
periodStart: Date,
periodEnd: Date,
): Promise<ResourceHolidayContext> {
const resource = typeof db.resource?.findUnique === "function"
? await db.resource.findUnique({
where: { id: resourceId },
select: {
federalState: true,
countryId: true,
metroCityId: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
},
})
: null;
const holidayVacations = typeof db.vacation?.findMany === "function"
? await db.vacation.findMany({
where: {
resourceId,
type: VacationType.PUBLIC_HOLIDAY,
status: VacationStatus.APPROVED,
startDate: { lte: periodEnd },
endDate: { gte: periodStart },
},
select: { startDate: true, endDate: true },
})
: [];
const calendarHolidayStrings = await getResolvedCalendarHolidayStrings(db, {
periodStart,
periodEnd,
countryId: resource?.countryId ?? null,
countryCode: resource?.country?.code ?? null,
federalState: resource?.federalState ?? null,
metroCityId: resource?.metroCityId ?? null,
metroCityName: resource?.metroCity?.name ?? null,
});
const publicHolidayStrings = new Set<string>();
for (const holiday of holidayVacations) {
const cursor = clampToDay(new Date(Math.max(holiday.startDate.getTime(), periodStart.getTime())));
const end = clampToDay(new Date(Math.min(holiday.endDate.getTime(), periodEnd.getTime())));
while (cursor <= end) {
publicHolidayStrings.add(toIsoDate(cursor));
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
return {
countryId: resource?.countryId ?? null,
countryCode: resource?.country?.code ?? null,
countryName: resource?.country?.name ?? null,
federalState: resource?.federalState ?? null,
metroCityId: resource?.metroCityId ?? null,
metroCityName: resource?.metroCity?.name ?? null,
calendarHolidayStrings,
publicHolidayStrings: [...publicHolidayStrings].sort(),
};
}
+112
View File
@@ -0,0 +1,112 @@
import { getCalendarHolidayStrings, toIsoDate } from "./holiday-availability.js";
type VacationSpan = {
startDate: Date;
endDate: Date;
isHalfDay: boolean;
};
type HolidayContext = {
countryCode?: string | null | undefined;
federalState?: string | null | undefined;
metroCityName?: string | null | undefined;
calendarHolidayStrings?: string[] | undefined;
publicHolidayStrings?: string[] | undefined;
};
type CountVacationChargeableDaysInput = HolidayContext & {
vacation: VacationSpan;
periodStart?: Date | undefined;
periodEnd?: Date | undefined;
};
function clampToDay(value: Date): Date {
const date = new Date(value);
date.setUTCHours(0, 0, 0, 0);
return date;
}
function getOverlapRange(
startDate: Date,
endDate: Date,
periodStart?: Date,
periodEnd?: Date,
): { start: Date; end: Date } | null {
const startBoundary = clampToDay(periodStart ?? startDate);
const endBoundary = clampToDay(periodEnd ?? endDate);
const overlapStart = clampToDay(new Date(Math.max(startDate.getTime(), startBoundary.getTime())));
const overlapEnd = clampToDay(new Date(Math.min(endDate.getTime(), endBoundary.getTime())));
if (overlapStart > overlapEnd) {
return null;
}
return { start: overlapStart, end: overlapEnd };
}
export function countCalendarDaysInPeriod(
vacation: VacationSpan,
periodStart?: Date,
periodEnd?: Date,
): number {
const overlap = getOverlapRange(vacation.startDate, vacation.endDate, periodStart, periodEnd);
if (!overlap) {
return 0;
}
if (vacation.isHalfDay) {
return 0.5;
}
const ms = overlap.end.getTime() - overlap.start.getTime();
return Math.round(ms / 86_400_000) + 1;
}
export function countVacationChargeableDays(
input: CountVacationChargeableDaysInput,
): number {
const overlap = getOverlapRange(
input.vacation.startDate,
input.vacation.endDate,
input.periodStart,
input.periodEnd,
);
if (!overlap) {
return 0;
}
const holidaySet = new Set(
input.calendarHolidayStrings
? input.calendarHolidayStrings.filter((isoDate) => isoDate >= toIsoDate(overlap.start) && isoDate <= toIsoDate(overlap.end))
: getCalendarHolidayStrings(
overlap.start,
overlap.end,
input.countryCode,
input.federalState,
input.metroCityName,
),
);
for (const isoDate of input.publicHolidayStrings ?? []) {
if (isoDate >= toIsoDate(overlap.start) && isoDate <= toIsoDate(overlap.end)) {
holidaySet.add(isoDate);
}
}
if (input.vacation.isHalfDay) {
return holidaySet.has(toIsoDate(overlap.start)) ? 0 : 0.5;
}
let total = 0;
const cursor = new Date(overlap.start);
while (cursor <= overlap.end) {
if (!holidaySet.has(toIsoDate(cursor))) {
total += 1;
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return total;
}