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
@@ -931,6 +931,74 @@ describe("dashboard use-cases", () => {
]); ]);
}); });
it("exposes calendar context summaries in peak times derivation", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_by",
displayName: "Bruce",
chapter: "CGI",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: "city_munich",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Munich" },
},
{
id: "res_hh",
displayName: "Harvey",
chapter: "CGI",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "HH",
metroCityId: "city_hamburg",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Hamburg" },
},
]),
},
};
const result = await getDashboardPeakTimes(db as never, {
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
granularity: "month",
groupBy: "chapter",
});
expect(result).toEqual([
expect.objectContaining({
period: "2026-01",
derivation: expect.objectContaining({
calendarContextCount: 2,
calendarLocations: [
expect.objectContaining({
countryCode: "DE",
countryName: "Germany",
federalState: "HH",
metroCityName: "Hamburg",
resourceCount: 1,
effectiveAvailableHours: 16,
}),
expect.objectContaining({
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Munich",
resourceCount: 1,
effectiveAvailableHours: 8,
}),
],
}),
}),
]);
});
it("does not burn budget on regional public holidays", async () => { it("does not burn budget on regional public holidays", async () => {
const db = { const db = {
project: { project: {
@@ -7,6 +7,7 @@ import {
calculateEffectiveAvailableHours, calculateEffectiveAvailableHours,
enumerateIsoDates, enumerateIsoDates,
loadDailyAvailabilityContexts, loadDailyAvailabilityContexts,
type DailyAvailabilityContext,
} from "./holiday-capacity.js"; } from "./holiday-capacity.js";
export interface GetDashboardPeakTimesInput { export interface GetDashboardPeakTimesInput {
@@ -19,8 +20,15 @@ export interface GetDashboardPeakTimesInput {
export interface PeakTimesPeriodDerivation { export interface PeakTimesPeriodDerivation {
periodStart: string; periodStart: string;
periodEnd: string; periodEnd: string;
calendarContextCount: number;
resourceCount: number; resourceCount: number;
groupCount: number; groupCount: number;
baseAvailableHours: number;
effectiveAvailableHours: number;
publicHolidayHoursDeduction: number;
absenceDayEquivalent: number;
absenceHoursDeduction: number;
calendarLocations: PeakTimesCalendarLocationSummary[];
bookedHours: number; bookedHours: number;
capacityHours: number; capacityHours: number;
remainingCapacityHours: number; remainingCapacityHours: number;
@@ -28,6 +36,15 @@ export interface PeakTimesPeriodDerivation {
utilizationPct: number; utilizationPct: number;
} }
export interface PeakTimesCalendarLocationSummary {
countryCode: string | null;
countryName: string | null;
federalState: string | null;
metroCityName: string | null;
resourceCount: number;
effectiveAvailableHours: number;
}
export interface PeakTimesGroupRow { export interface PeakTimesGroupRow {
name: string; name: string;
hours: number; hours: number;
@@ -53,6 +70,167 @@ export interface PeakTimesPeriodRow {
derivation: PeakTimesPeriodDerivation; 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( export async function getDashboardPeakTimes(
db: PrismaClient, db: PrismaClient,
input: GetDashboardPeakTimesInput, input: GetDashboardPeakTimesInput,
@@ -75,6 +253,7 @@ export async function getDashboardPeakTimes(
country: { country: {
select: { select: {
code: true, code: true,
name: true,
}, },
}, },
metroCity: { metroCity: {
@@ -105,6 +284,7 @@ export async function getDashboardPeakTimes(
availability: resource.availability as unknown as WeekdayAvailability, availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId, countryId: resource.countryId,
countryCode: resource.country?.code, countryCode: resource.country?.code,
countryName: resource.country?.name,
federalState: resource.federalState, federalState: resource.federalState,
metroCityId: resource.metroCityId, metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name, metroCityName: resource.metroCity?.name,
@@ -162,16 +342,32 @@ export async function getDashboardPeakTimes(
} }
} }
const capacityByBucket = new Map<string, number>(); const capacityByBucket = new Map<string, number>();
const derivationByBucket = new Map<string, PeakTimesCapacityDerivationSummary>();
for (const [bucketKey, bucketPeriod] of bucketPeriods.entries()) { for (const [bucketKey, bucketPeriod] of bucketPeriods.entries()) {
let capacityHours = 0; 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()) { for (const resource of resourceMap.values()) {
const effectiveAvailableHours = calculateEffectiveAvailableHours({ const capacityDerivation = summarizeCapacityDerivation(
availability: resource.availability, resource.availability,
periodStart: bucketPeriod.start, bucketPeriod.start,
periodEnd: bucketPeriod.end, bucketPeriod.end,
context: contexts.get(resource.id), contexts.get(resource.id),
}); );
const effectiveAvailableHours = capacityDerivation.effectiveAvailableHours;
capacityHours += 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) { if (input.groupBy !== "project" && effectiveAvailableHours > 0) {
const group = 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); capacityByBucket.set(bucketKey, capacityHours);
derivationByBucket.set(bucketKey, derivationTotals);
} }
return [...bucketPeriods.entries()] return [...bucketPeriods.entries()]
@@ -232,6 +443,16 @@ export async function getDashboardPeakTimes(
); );
const totalHours = [...groups.values()].reduce((sum, hours) => sum + hours, 0); const totalHours = [...groups.values()].reduce((sum, hours) => sum + hours, 0);
const capacityHours = capacityByBucket.get(period) ?? 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 remainingCapacityHours = Math.max(0, capacityHours - totalHours);
const overbookedHours = Math.max(0, totalHours - capacityHours); const overbookedHours = Math.max(0, totalHours - capacityHours);
@@ -253,8 +474,15 @@ export async function getDashboardPeakTimes(
derivation: { derivation: {
periodStart: bucketPeriod.start.toISOString().slice(0, 10), periodStart: bucketPeriod.start.toISOString().slice(0, 10),
periodEnd: bucketPeriod.end.toISOString().slice(0, 10), periodEnd: bucketPeriod.end.toISOString().slice(0, 10),
calendarContextCount: capacityDerivation.calendarContextCount,
resourceCount: resourceMap.size, resourceCount: resourceMap.size,
groupCount: groupRows.length, 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, bookedHours: totalHours,
capacityHours, capacityHours,
remainingCapacityHours, remainingCapacityHours,