import type { PrismaClient } from "@capakraken/db"; import { toIsoDate, DAY_KEYS, type WeekdayAvailability } from "@capakraken/shared"; import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js"; import { getMonthBucketKey, getWeekBucketKey } from "./shared.js"; import { calculateEffectiveAllocationHours, calculateEffectiveAvailableHours, enumerateIsoDates, loadDailyAvailabilityContexts, type DailyAvailabilityContext, } from "./holiday-capacity.js"; export interface GetDashboardPeakTimesInput { startDate: Date; endDate: Date; granularity: "week" | "month"; groupBy: "project" | "chapter" | "resource"; } 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; overbookedHours: 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 { name: string; hours: number; capacityHours: number | undefined; remainingHours: number | undefined; overbookedHours: number | undefined; utilizationPct: number | undefined; } export interface PeakTimesPeriodRow { period: string; groups: PeakTimesGroupRow[]; totalHours: number; capacityHours: number; periodStart?: string; periodEnd?: string; bookedHours?: number; remainingHours?: number; overbookedHours?: number; utilizationPct?: number; groupCount?: number; resourceCount?: number; derivation: PeakTimesPeriodDerivation; } type PeakTimesCapacityDerivationSummary = Pick< PeakTimesPeriodDerivation, | "baseAvailableHours" | "effectiveAvailableHours" | "publicHolidayHoursDeduction" | "absenceDayEquivalent" | "absenceHoursDeduction" | "calendarContextCount" | "calendarLocations" >; 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, ): Promise { const [allocations, resources] = await Promise.all([ listAssignmentBookings(db, { startDate: input.startDate, endDate: input.endDate, }), db.resource.findMany({ where: { isActive: true }, select: { id: true, displayName: true, chapter: true, availability: true, countryId: true, federalState: true, metroCityId: true, country: { select: { code: true, name: true, }, }, metroCity: { select: { name: true, }, }, }, }), ]); const buckets = new Map>(); const groupCapacityBuckets = new Map>(); const getBucketKey = input.granularity === "week" ? getWeekBucketKey : getMonthBucketKey; const resourceMap = new Map( resources.map((resource) => [ resource.id, { ...resource, availability: resource.availability as unknown as WeekdayAvailability, }, ]), ); const contexts = await loadDailyAvailabilityContexts( db, resources.map((resource) => ({ id: resource.id, 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, })), input.startDate, input.endDate, ); const bucketPeriods = new Map(); for (const isoDate of enumerateIsoDates(input.startDate, input.endDate)) { const date = new Date(`${isoDate}T00:00:00.000Z`); const bucketKey = getBucketKey(date); const existing = bucketPeriods.get(bucketKey); if (!existing) { bucketPeriods.set(bucketKey, { start: date, end: date }); continue; } if (date < existing.start) { existing.start = date; } if (date > existing.end) { existing.end = date; } } for (const bucketKey of bucketPeriods.keys()) { buckets.set(bucketKey, new Map()); groupCapacityBuckets.set(bucketKey, new Map()); } for (const allocation of allocations) { const resource = allocation.resourceId ? resourceMap.get(allocation.resourceId) : undefined; const group = input.groupBy === "project" ? allocation.project.shortCode : input.groupBy === "chapter" ? allocation.resource?.chapter ?? "Unassigned" : allocation.resource?.displayName ?? "Unknown"; for (const [bucketKey, bucketPeriod] of bucketPeriods.entries()) { const hours = resource ? calculateEffectiveAllocationHours({ availability: resource.availability, startDate: allocation.startDate, endDate: allocation.endDate, hoursPerDay: allocation.hoursPerDay, periodStart: bucketPeriod.start, periodEnd: bucketPeriod.end, context: contexts.get(resource.id), }) : 0; if (hours <= 0) { continue; } const bucket = buckets.get(bucketKey)!; bucket.set(group, (bucket.get(group) ?? 0) + hours); } } 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 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 = input.groupBy === "chapter" ? resource.chapter ?? "Unassigned" : resource.displayName ?? "Unknown"; const groupCapacityBucket = groupCapacityBuckets.get(bucketKey)!; groupCapacityBucket.set( group, (groupCapacityBucket.get(group) ?? 0) + effectiveAvailableHours, ); } } 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()] .sort(([left], [right]) => left.localeCompare(right)) .map(([period, bucketPeriod]) => { const groups = buckets.get(period) ?? new Map(); const groupCapacities = groupCapacityBuckets.get(period) ?? new Map(); const groupNames = new Set([ ...groups.keys(), ...(input.groupBy === "project" ? [] : groupCapacities.keys()), ]); const groupRows = [...groupNames] .map((name) => { const hours = groups.get(name) ?? 0; const groupCapacityHours = input.groupBy === "project" ? undefined : groupCapacities.get(name) ?? 0; const remainingHours = groupCapacityHours === undefined ? undefined : Math.max(0, groupCapacityHours - hours); const overbookedHours = groupCapacityHours === undefined ? undefined : Math.max(0, hours - groupCapacityHours); return { name, hours, capacityHours: groupCapacityHours, remainingHours, overbookedHours, utilizationPct: groupCapacityHours && groupCapacityHours > 0 ? Math.round((hours / groupCapacityHours) * 100) : groupCapacityHours === 0 ? 0 : undefined, }; }) .sort( (left, right) => (right.utilizationPct ?? -1) - (left.utilizationPct ?? -1) || right.hours - left.hours || left.name.localeCompare(right.name), ); 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); return { period, groups: groupRows, totalHours, capacityHours, periodStart: bucketPeriod.start.toISOString().slice(0, 10), periodEnd: bucketPeriod.end.toISOString().slice(0, 10), bookedHours: totalHours, remainingHours: remainingCapacityHours, overbookedHours, utilizationPct: capacityHours > 0 ? Math.round((totalHours / capacityHours) * 100) : 0, groupCount: groupRows.length, resourceCount: resourceMap.size, 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, overbookedHours, utilizationPct: capacityHours > 0 ? Math.round((totalHours / capacityHours) * 100) : 0, }, }; }); }