Files
CapaKraken/packages/application/src/use-cases/dashboard/get-peak-times.ts
T

269 lines
8.6 KiB
TypeScript

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<PeakTimesPeriodRow[]> {
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<string, Map<string, number>>();
const groupCapacityBuckets = new Map<string, Map<string, number>>();
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<string, { start: Date; end: Date }>();
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<string, number>();
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<string, number>();
const groupCapacities = groupCapacityBuckets.get(period) ?? new Map<string, number>();
const groupNames = new Set<string>([
...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,
},
};
});
}