feat(application): expose peak time calendar contexts
This commit is contained in:
@@ -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 () => {
|
||||
const db = {
|
||||
project: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user