feat(application): expose peak time calendar contexts

This commit is contained in:
2026-03-31 22:46:19 +02:00
parent ba2bf00712
commit 5097ceab7e
2 changed files with 302 additions and 6 deletions
@@ -7,6 +7,7 @@ import {
calculateEffectiveAvailableHours,
enumerateIsoDates,
loadDailyAvailabilityContexts,
type DailyAvailabilityContext,
} from "./holiday-capacity.js";
export interface GetDashboardPeakTimesInput {
@@ -19,8 +20,15 @@ export interface GetDashboardPeakTimesInput {
export interface PeakTimesPeriodDerivation {
periodStart: string;
periodEnd: string;
calendarContextCount: number;
resourceCount: number;
groupCount: number;
baseAvailableHours: number;
effectiveAvailableHours: number;
publicHolidayHoursDeduction: number;
absenceDayEquivalent: number;
absenceHoursDeduction: number;
calendarLocations: PeakTimesCalendarLocationSummary[];
bookedHours: number;
capacityHours: number;
remainingCapacityHours: number;
@@ -28,6 +36,15 @@ export interface PeakTimesPeriodDerivation {
utilizationPct: number;
}
export interface PeakTimesCalendarLocationSummary {
countryCode: string | null;
countryName: string | null;
federalState: string | null;
metroCityName: string | null;
resourceCount: number;
effectiveAvailableHours: number;
}
export interface PeakTimesGroupRow {
name: string;
hours: number;
@@ -53,6 +70,167 @@ export interface PeakTimesPeriodRow {
derivation: PeakTimesPeriodDerivation;
}
type PeakTimesCapacityDerivationSummary = Pick<
PeakTimesPeriodDerivation,
| "baseAvailableHours"
| "effectiveAvailableHours"
| "publicHolidayHoursDeduction"
| "absenceDayEquivalent"
| "absenceHoursDeduction"
| "calendarContextCount"
| "calendarLocations"
>;
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
function buildLocationKey(input: {
countryCode: string | null | undefined;
countryName: string | null | undefined;
federalState: string | null | undefined;
metroCityName: string | null | undefined;
}): string {
return JSON.stringify({
countryCode: input.countryCode ?? null,
countryName: input.countryName ?? null,
federalState: input.federalState ?? null,
metroCityName: input.metroCityName ?? null,
});
}
function getDailyAvailabilityHours(
availability: WeekdayAvailability,
date: Date,
): number {
const dayKey = DAY_KEYS[date.getUTCDay()];
return dayKey ? (availability[dayKey] ?? 0) : 0;
}
function summarizeCapacityDerivation(
availability: WeekdayAvailability,
periodStart: Date,
periodEnd: Date,
context: DailyAvailabilityContext | undefined,
) {
let publicHolidayHoursDeduction = 0;
let absenceDayEquivalent = 0;
let absenceHoursDeduction = 0;
const baseAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart,
periodEnd,
context: undefined,
});
const effectiveAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart,
periodEnd,
context,
});
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) {
const isoDate = toIsoDate(cursor);
const baseHours = getDailyAvailabilityHours(availability, cursor);
const absenceFraction = Math.min(
1,
Math.max(0, context?.absenceFractionsByDate.get(isoDate) ?? 0),
);
const isHoliday = context?.holidayDates.has(isoDate) ?? false;
if (baseHours > 0) {
if (isHoliday) {
publicHolidayHoursDeduction += baseHours;
} else if (absenceFraction > 0) {
absenceDayEquivalent += absenceFraction;
absenceHoursDeduction += baseHours * absenceFraction;
}
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return {
baseAvailableHours,
effectiveAvailableHours,
publicHolidayHoursDeduction,
absenceDayEquivalent,
absenceHoursDeduction,
};
}
function summarizeCalendarLocations(
resources: Array<{
id: string;
availability: WeekdayAvailability;
countryCode: string | null | undefined;
countryName: string | null | undefined;
federalState: string | null | undefined;
metroCityName: string | null | undefined;
}>,
contexts: Map<string, DailyAvailabilityContext>,
periodStart: Date,
periodEnd: Date,
): PeakTimesCalendarLocationSummary[] {
const locationMap = new Map<string, PeakTimesCalendarLocationSummary & { resourceIds: Set<string> }>();
for (const resource of resources) {
const capacityDerivation = summarizeCapacityDerivation(
resource.availability,
periodStart,
periodEnd,
contexts.get(resource.id),
);
const locationKey = buildLocationKey({
countryCode: resource.countryCode,
countryName: resource.countryName,
federalState: resource.federalState,
metroCityName: resource.metroCityName,
});
const existing = locationMap.get(locationKey) ?? {
countryCode: resource.countryCode ?? null,
countryName: resource.countryName ?? null,
federalState: resource.federalState ?? null,
metroCityName: resource.metroCityName ?? null,
resourceCount: 0,
effectiveAvailableHours: 0,
resourceIds: new Set<string>(),
};
existing.effectiveAvailableHours += capacityDerivation.effectiveAvailableHours;
existing.resourceIds.add(resource.id);
existing.resourceCount = existing.resourceIds.size;
locationMap.set(locationKey, existing);
}
return [...locationMap.values()]
.map(({ resourceIds: _resourceIds, ...summary }) => ({
...summary,
effectiveAvailableHours: round1(summary.effectiveAvailableHours),
}))
.sort((left, right) => right.effectiveAvailableHours - left.effectiveAvailableHours);
}
export async function getDashboardPeakTimes(
db: PrismaClient,
input: GetDashboardPeakTimesInput,
@@ -75,6 +253,7 @@ export async function getDashboardPeakTimes(
country: {
select: {
code: true,
name: true,
},
},
metroCity: {
@@ -105,6 +284,7 @@ export async function getDashboardPeakTimes(
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
countryName: resource.country?.name,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
@@ -162,16 +342,32 @@ export async function getDashboardPeakTimes(
}
}
const capacityByBucket = new Map<string, number>();
const derivationByBucket = new Map<string, PeakTimesCapacityDerivationSummary>();
for (const [bucketKey, bucketPeriod] of bucketPeriods.entries()) {
let capacityHours = 0;
const derivationTotals: PeakTimesCapacityDerivationSummary = {
baseAvailableHours: 0,
effectiveAvailableHours: 0,
publicHolidayHoursDeduction: 0,
absenceDayEquivalent: 0,
absenceHoursDeduction: 0,
calendarContextCount: 0,
calendarLocations: [] as PeakTimesCalendarLocationSummary[],
};
for (const resource of resourceMap.values()) {
const effectiveAvailableHours = calculateEffectiveAvailableHours({
availability: resource.availability,
periodStart: bucketPeriod.start,
periodEnd: bucketPeriod.end,
context: contexts.get(resource.id),
});
const capacityDerivation = summarizeCapacityDerivation(
resource.availability,
bucketPeriod.start,
bucketPeriod.end,
contexts.get(resource.id),
);
const effectiveAvailableHours = capacityDerivation.effectiveAvailableHours;
capacityHours += effectiveAvailableHours;
derivationTotals.baseAvailableHours += capacityDerivation.baseAvailableHours;
derivationTotals.effectiveAvailableHours += capacityDerivation.effectiveAvailableHours;
derivationTotals.publicHolidayHoursDeduction += capacityDerivation.publicHolidayHoursDeduction;
derivationTotals.absenceDayEquivalent += capacityDerivation.absenceDayEquivalent;
derivationTotals.absenceHoursDeduction += capacityDerivation.absenceHoursDeduction;
if (input.groupBy !== "project" && effectiveAvailableHours > 0) {
const group =
@@ -185,7 +381,22 @@ export async function getDashboardPeakTimes(
);
}
}
derivationTotals.calendarLocations = summarizeCalendarLocations(
[...resourceMap.values()].map((resource) => ({
id: resource.id,
availability: resource.availability,
countryCode: resource.country?.code,
countryName: resource.country?.name,
federalState: resource.federalState,
metroCityName: resource.metroCity?.name,
})),
contexts,
bucketPeriod.start,
bucketPeriod.end,
);
derivationTotals.calendarContextCount = derivationTotals.calendarLocations.length;
capacityByBucket.set(bucketKey, capacityHours);
derivationByBucket.set(bucketKey, derivationTotals);
}
return [...bucketPeriods.entries()]
@@ -232,6 +443,16 @@ export async function getDashboardPeakTimes(
);
const totalHours = [...groups.values()].reduce((sum, hours) => sum + hours, 0);
const capacityHours = capacityByBucket.get(period) ?? 0;
const capacityDerivation: PeakTimesCapacityDerivationSummary =
derivationByBucket.get(period) ?? {
baseAvailableHours: capacityHours,
effectiveAvailableHours: capacityHours,
publicHolidayHoursDeduction: 0,
absenceDayEquivalent: 0,
absenceHoursDeduction: 0,
calendarContextCount: 0,
calendarLocations: [] as PeakTimesCalendarLocationSummary[],
};
const remainingCapacityHours = Math.max(0, capacityHours - totalHours);
const overbookedHours = Math.max(0, totalHours - capacityHours);
@@ -253,8 +474,15 @@ export async function getDashboardPeakTimes(
derivation: {
periodStart: bucketPeriod.start.toISOString().slice(0, 10),
periodEnd: bucketPeriod.end.toISOString().slice(0, 10),
calendarContextCount: capacityDerivation.calendarContextCount,
resourceCount: resourceMap.size,
groupCount: groupRows.length,
baseAvailableHours: capacityDerivation.baseAvailableHours,
effectiveAvailableHours: capacityDerivation.effectiveAvailableHours,
publicHolidayHoursDeduction: capacityDerivation.publicHolidayHoursDeduction,
absenceDayEquivalent: Math.round(capacityDerivation.absenceDayEquivalent * 10) / 10,
absenceHoursDeduction: capacityDerivation.absenceHoursDeduction,
calendarLocations: capacityDerivation.calendarLocations,
bookedHours: totalHours,
capacityHours,
remainingCapacityHours,