feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
|
||||
import { getAverageDailyAvailabilityHours, getMonthBucketKey, getWeekBucketKey } from "./shared.js";
|
||||
import { getMonthBucketKey, getWeekBucketKey } from "./shared.js";
|
||||
import {
|
||||
calculateEffectiveAllocationHours,
|
||||
calculateEffectiveAvailableHours,
|
||||
enumerateIsoDates,
|
||||
loadDailyAvailabilityContexts,
|
||||
} from "./holiday-capacity.js";
|
||||
|
||||
export interface GetDashboardPeakTimesInput {
|
||||
startDate: Date;
|
||||
@@ -9,67 +16,253 @@ export interface GetDashboardPeakTimesInput {
|
||||
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,
|
||||
) {
|
||||
const allocations = await listAssignmentBookings(db, {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
});
|
||||
): 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 allocStart = new Date(
|
||||
Math.max(allocation.startDate.getTime(), input.startDate.getTime()),
|
||||
);
|
||||
const allocEnd = new Date(
|
||||
Math.min(allocation.endDate.getTime(), input.endDate.getTime()),
|
||||
);
|
||||
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";
|
||||
|
||||
const cursor = new Date(allocStart);
|
||||
while (cursor <= allocEnd) {
|
||||
const bucketKey = getBucketKey(cursor);
|
||||
if (!buckets.has(bucketKey)) {
|
||||
buckets.set(bucketKey, new Map());
|
||||
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) + allocation.hoursPerDay);
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
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;
|
||||
|
||||
const resources = await db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: { availability: true },
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
const dailyCapacityHours = resources.reduce(
|
||||
(sum, resource) =>
|
||||
sum +
|
||||
getAverageDailyAvailabilityHours(
|
||||
resource.availability as Record<string, number | null | undefined>,
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
return [...buckets.entries()]
|
||||
return [...bucketPeriods.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([period, groups]) => ({
|
||||
period,
|
||||
groups: [...groups.entries()].map(([name, hours]) => ({ name, hours })),
|
||||
totalHours: [...groups.values()].reduce((sum, hours) => sum + hours, 0),
|
||||
capacityHours:
|
||||
dailyCapacityHours * (input.granularity === "week" ? 5 : 22),
|
||||
}));
|
||||
.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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user