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 () => {
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user