1df208dbcc
Allocation bars that have active optimistic overrides (post-drag, awaiting server confirmation) now pulse subtly via animate-pulse. The pending set is derived from the existing optimisticAllocations map keys, requiring no additional state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
483 lines
16 KiB
TypeScript
483 lines
16 KiB
TypeScript
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<string, DailyAvailabilityContext>,
|
|
periodStart: Date,
|
|
periodEnd: Date,
|
|
): PeakTimesCalendarLocationSummary[] {
|
|
const locationMap = new Map<string, PeakTimesCalendarLocationSummary & { resourceIds: Set<string> }>();
|
|
|
|
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<string>(),
|
|
};
|
|
|
|
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<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,
|
|
name: 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,
|
|
countryName: resource.country?.name,
|
|
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>();
|
|
const derivationByBucket = new Map<string, PeakTimesCapacityDerivationSummary>();
|
|
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<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 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,
|
|
},
|
|
};
|
|
});
|
|
}
|