import type { PrismaClient } from "@capakraken/db"; import type { WeekdayAvailability } from "@capakraken/shared"; import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js"; import { getMonthBucketKey, getWeekBucketKey } from "./shared.js"; import { calculateEffectiveAllocationHours, calculateEffectiveAvailableHours, enumerateIsoDates, loadDailyAvailabilityContexts, } 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; resourceCount: number; groupCount: number; bookedHours: number; capacityHours: number; remainingCapacityHours: number; overbookedHours: number; utilizationPct: 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; } 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, }, }, 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, 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(); for (const [bucketKey, bucketPeriod] of bucketPeriods.entries()) { let capacityHours = 0; for (const resource of resourceMap.values()) { const effectiveAvailableHours = calculateEffectiveAvailableHours({ availability: resource.availability, periodStart: bucketPeriod.start, periodEnd: bucketPeriod.end, context: contexts.get(resource.id), }); capacityHours += effectiveAvailableHours; 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, ); } } capacityByBucket.set(bucketKey, capacityHours); } 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 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), resourceCount: resourceMap.size, groupCount: groupRows.length, bookedHours: totalHours, capacityHours, remainingCapacityHours, overbookedHours, utilizationPct: capacityHours > 0 ? Math.round((totalHours / capacityHours) * 100) : 0, }, }; }); }