diff --git a/packages/application/src/__tests__/dashboard.test.ts b/packages/application/src/__tests__/dashboard.test.ts index 495a8a9..c172226 100644 --- a/packages/application/src/__tests__/dashboard.test.ts +++ b/packages/application/src/__tests__/dashboard.test.ts @@ -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: { diff --git a/packages/application/src/use-cases/dashboard/get-peak-times.ts b/packages/application/src/use-cases/dashboard/get-peak-times.ts index c836da0..c80257f 100644 --- a/packages/application/src/use-cases/dashboard/get-peak-times.ts +++ b/packages/application/src/use-cases/dashboard/get-peak-times.ts @@ -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, + periodStart: Date, + periodEnd: Date, +): PeakTimesCalendarLocationSummary[] { + const locationMap = new Map }>(); + + 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(), + }; + + 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(); + const derivationByBucket = new Map(); 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,