Files
CapaKraken/packages/application/src/use-cases/dashboard/get-peak-times.ts
T
Hartmut 1df208dbcc feat(timeline): add pulse animation for in-flight drag mutations
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>
2026-04-09 13:28:46 +02:00

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,
},
};
});
}