feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { loadDashboardPlanningReadModel } from "./load-dashboard-planning-read-model.js";
|
||||
import { calculateAllocationHours } from "./shared.js";
|
||||
import {
|
||||
calculateEffectiveAllocationHours,
|
||||
calculateEffectiveAvailableHours,
|
||||
loadDailyAvailabilityContexts,
|
||||
} from "./holiday-capacity.js";
|
||||
|
||||
export interface GetDashboardDemandInput {
|
||||
startDate: Date;
|
||||
@@ -8,6 +14,35 @@ export interface GetDashboardDemandInput {
|
||||
groupBy: "project" | "person" | "chapter";
|
||||
}
|
||||
|
||||
export interface DemandCalendarLocationSummary {
|
||||
countryCode: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
resourceCount: number;
|
||||
allocatedHours: number;
|
||||
}
|
||||
|
||||
export interface DemandRowDerivation {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
periodWorkingHoursBase: number;
|
||||
requiredHours: number | null;
|
||||
requiredFTEs: number;
|
||||
fillPct: number | null;
|
||||
demandSource: "DEMAND_REQUIREMENTS" | "PROJECT_STAFFING_REQS" | "NONE";
|
||||
calendarLocations: DemandCalendarLocationSummary[];
|
||||
}
|
||||
|
||||
export interface DashboardDemandRow {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
allocatedHours: number;
|
||||
requiredFTEs: number;
|
||||
resourceCount: number;
|
||||
derivation?: DemandRowDerivation;
|
||||
}
|
||||
|
||||
interface ProjectSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -15,6 +50,12 @@ interface ProjectSummary {
|
||||
staffingReqs: unknown;
|
||||
}
|
||||
|
||||
function hasAvailability<T extends { availability?: unknown }>(
|
||||
resource: T | null | undefined,
|
||||
): resource is T & { availability: WeekdayAvailability } {
|
||||
return resource !== null && resource !== undefined && resource.availability !== null && resource.availability !== undefined;
|
||||
}
|
||||
|
||||
function getDemandFteFactor(hoursPerDay: number, percentage: number): number {
|
||||
const normalizedPercentage = percentage > 0 ? percentage : (hoursPerDay / 8) * 100;
|
||||
return normalizedPercentage / 100;
|
||||
@@ -24,6 +65,22 @@ function toDate(value: Date | string): Date {
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function buildLocationKey(input: {
|
||||
countryCode: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
countryCode: input.countryCode ?? null,
|
||||
federalState: input.federalState ?? null,
|
||||
metroCityName: input.metroCityName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function getProjectRequiredFTEs(staffingReqs: unknown): number {
|
||||
const requirements = Array.isArray(staffingReqs) ? staffingReqs : [];
|
||||
return requirements.reduce((sum, requirement) => {
|
||||
@@ -40,10 +97,84 @@ function getProjectRequiredFTEs(staffingReqs: unknown): number {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const FULL_TIME_AVAILABILITY: WeekdayAvailability = {
|
||||
sunday: 0,
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
};
|
||||
|
||||
function summarizeCalendarLocations(
|
||||
assignments: Array<{
|
||||
resource?: { id?: string | null } | null;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
}>,
|
||||
resourceInfoById: Map<string, {
|
||||
id: string;
|
||||
availability: WeekdayAvailability;
|
||||
countryCode: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
}>,
|
||||
contexts: Map<string, Awaited<ReturnType<typeof loadDailyAvailabilityContexts>> extends Map<string, infer T> ? T : never>,
|
||||
input: GetDashboardDemandInput,
|
||||
): DemandCalendarLocationSummary[] {
|
||||
const locationMap = new Map<string, DemandCalendarLocationSummary & { resourceIds: Set<string> }>();
|
||||
|
||||
for (const assignment of assignments) {
|
||||
const resourceId = assignment.resource?.id ?? undefined;
|
||||
const resource = resourceId ? resourceInfoById.get(resourceId) : undefined;
|
||||
if (!resource) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hours = calculateEffectiveAllocationHours({
|
||||
availability: resource.availability,
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context: contexts.get(resource.id),
|
||||
});
|
||||
|
||||
const locationKey = buildLocationKey({
|
||||
countryCode: resource.countryCode,
|
||||
federalState: resource.federalState,
|
||||
metroCityName: resource.metroCityName,
|
||||
});
|
||||
const existing = locationMap.get(locationKey) ?? {
|
||||
countryCode: resource.countryCode ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityName: resource.metroCityName ?? null,
|
||||
resourceCount: 0,
|
||||
allocatedHours: 0,
|
||||
resourceIds: new Set<string>(),
|
||||
};
|
||||
|
||||
existing.allocatedHours += hours;
|
||||
existing.resourceIds.add(resource.id);
|
||||
existing.resourceCount = existing.resourceIds.size;
|
||||
locationMap.set(locationKey, existing);
|
||||
}
|
||||
|
||||
return [...locationMap.values()]
|
||||
.map(({ resourceIds: _resourceIds, ...summary }) => ({
|
||||
...summary,
|
||||
allocatedHours: Math.round(summary.allocatedHours * 10) / 10,
|
||||
}))
|
||||
.sort((left, right) => right.allocatedHours - left.allocatedHours);
|
||||
}
|
||||
|
||||
export async function getDashboardDemand(
|
||||
db: PrismaClient,
|
||||
input: GetDashboardDemandInput,
|
||||
) {
|
||||
): Promise<DashboardDemandRow[]> {
|
||||
const { demandRequirements, assignments, projects, readModel } =
|
||||
await loadDashboardPlanningReadModel(db, {
|
||||
startDate: input.startDate,
|
||||
@@ -58,6 +189,27 @@ export async function getDashboardDemand(
|
||||
);
|
||||
const normalizedAssignments = readModel.assignments;
|
||||
const normalizedDemands = readModel.demands;
|
||||
const resourceProfiles = assignments
|
||||
.map((assignment) => assignment.resource)
|
||||
.filter(hasAvailability)
|
||||
.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,
|
||||
}));
|
||||
const resourceInfoById = new Map(
|
||||
resourceProfiles.map((resource) => [resource.id, resource]),
|
||||
);
|
||||
const contexts = await loadDailyAvailabilityContexts(
|
||||
db,
|
||||
[...new Map(resourceProfiles.map((resource) => [resource.id, resource])).values()],
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
);
|
||||
|
||||
const projectMap = new Map<string, ProjectSummary>(
|
||||
projects.map((project) => [project.id, project]),
|
||||
@@ -87,6 +239,13 @@ export async function getDashboardDemand(
|
||||
);
|
||||
}
|
||||
|
||||
const periodWorkingHoursBase = calculateEffectiveAvailableHours({
|
||||
availability: FULL_TIME_AVAILABILITY,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context: undefined,
|
||||
});
|
||||
|
||||
if (input.groupBy === "project") {
|
||||
const projectIds = new Set<string>([
|
||||
...projectMap.keys(),
|
||||
@@ -110,13 +269,28 @@ export async function getDashboardDemand(
|
||||
);
|
||||
|
||||
const allocatedHours = projectAssignments.reduce(
|
||||
(sum, assignment) =>
|
||||
sum +
|
||||
calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
}),
|
||||
(sum, assignment) => {
|
||||
const resource = assignment.resource?.id
|
||||
? resourceInfoById.get(assignment.resource.id)
|
||||
: undefined;
|
||||
return sum + (
|
||||
resource
|
||||
? calculateEffectiveAllocationHours({
|
||||
availability: resource.availability,
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context: contexts.get(resource.id),
|
||||
})
|
||||
: calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
})
|
||||
);
|
||||
},
|
||||
0,
|
||||
);
|
||||
const requiredFTEs =
|
||||
@@ -139,6 +313,18 @@ export async function getDashboardDemand(
|
||||
return sum + plannedHeadcount * demandFteFactor;
|
||||
}, 0)
|
||||
: getProjectRequiredFTEs(project.staffingReqs);
|
||||
const requiredHours = requiredFTEs > 0
|
||||
? Math.round(requiredFTEs * periodWorkingHoursBase * 10) / 10
|
||||
: null;
|
||||
const fillPct = requiredHours && requiredHours > 0
|
||||
? Math.round((allocatedHours / requiredHours) * 100)
|
||||
: null;
|
||||
const calendarLocations = summarizeCalendarLocations(
|
||||
projectAssignments,
|
||||
resourceInfoById,
|
||||
contexts,
|
||||
input,
|
||||
);
|
||||
|
||||
return {
|
||||
id: project.id,
|
||||
@@ -149,6 +335,18 @@ export async function getDashboardDemand(
|
||||
resourceCount: new Set(
|
||||
projectAssignments.map((assignment) => assignment.resource?.id).filter(Boolean),
|
||||
).size,
|
||||
derivation: {
|
||||
periodStart: toIsoDate(input.startDate),
|
||||
periodEnd: toIsoDate(input.endDate),
|
||||
periodWorkingHoursBase,
|
||||
requiredHours,
|
||||
requiredFTEs: Math.round(requiredFTEs * 100) / 100,
|
||||
fillPct,
|
||||
demandSource: projectDemands.length > 0
|
||||
? "DEMAND_REQUIREMENTS"
|
||||
: "PROJECT_STAFFING_REQS",
|
||||
calendarLocations,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -161,6 +359,9 @@ export async function getDashboardDemand(
|
||||
|
||||
for (const assignment of normalizedAssignments) {
|
||||
const chapter = assignment.resource?.chapter ?? "Unassigned";
|
||||
const resource = assignment.resource?.id
|
||||
? resourceInfoById.get(assignment.resource.id)
|
||||
: undefined;
|
||||
const existing = chapterMap.get(chapter) ?? {
|
||||
allocatedHours: 0,
|
||||
resourceIds: new Set<string>(),
|
||||
@@ -170,23 +371,54 @@ export async function getDashboardDemand(
|
||||
existing.resourceIds.add(assignment.resource.id);
|
||||
}
|
||||
|
||||
existing.allocatedHours += calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
});
|
||||
existing.allocatedHours += resource
|
||||
? calculateEffectiveAllocationHours({
|
||||
availability: resource.availability,
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context: contexts.get(resource.id),
|
||||
})
|
||||
: calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
});
|
||||
|
||||
chapterMap.set(chapter, existing);
|
||||
}
|
||||
|
||||
return [...chapterMap.entries()].map(([chapter, data]) => ({
|
||||
id: chapter,
|
||||
name: chapter,
|
||||
shortCode: chapter,
|
||||
allocatedHours: Math.round(data.allocatedHours),
|
||||
requiredFTEs: 0,
|
||||
resourceCount: data.resourceIds.size,
|
||||
}));
|
||||
return [...chapterMap.entries()].map(([chapter, data]) => {
|
||||
const chapterAssignments = normalizedAssignments.filter(
|
||||
(assignment) => (assignment.resource?.chapter ?? "Unassigned") === chapter,
|
||||
);
|
||||
|
||||
return {
|
||||
id: chapter,
|
||||
name: chapter,
|
||||
shortCode: chapter,
|
||||
allocatedHours: Math.round(data.allocatedHours),
|
||||
requiredFTEs: 0,
|
||||
resourceCount: data.resourceIds.size,
|
||||
derivation: {
|
||||
periodStart: toIsoDate(input.startDate),
|
||||
periodEnd: toIsoDate(input.endDate),
|
||||
periodWorkingHoursBase,
|
||||
requiredHours: null,
|
||||
requiredFTEs: 0,
|
||||
fillPct: null,
|
||||
demandSource: "NONE",
|
||||
calendarLocations: summarizeCalendarLocations(
|
||||
chapterAssignments,
|
||||
resourceInfoById,
|
||||
contexts,
|
||||
input,
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const personMap = new Map<
|
||||
@@ -210,23 +442,55 @@ export async function getDashboardDemand(
|
||||
allocatedHours: 0,
|
||||
projectIds: new Set<string>(),
|
||||
};
|
||||
const resource = resourceInfoById.get(assignment.resource.id);
|
||||
|
||||
existing.allocatedHours += calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
});
|
||||
existing.allocatedHours += resource
|
||||
? calculateEffectiveAllocationHours({
|
||||
availability: resource.availability,
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context: contexts.get(resource.id),
|
||||
})
|
||||
: calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
});
|
||||
existing.projectIds.add(assignment.projectId);
|
||||
|
||||
personMap.set(assignment.resource.id, existing);
|
||||
}
|
||||
|
||||
return [...personMap.entries()].map(([id, data]) => ({
|
||||
id,
|
||||
name: data.name,
|
||||
shortCode: data.chapter ?? "",
|
||||
allocatedHours: Math.round(data.allocatedHours),
|
||||
requiredFTEs: 0,
|
||||
resourceCount: data.projectIds.size,
|
||||
}));
|
||||
return [...personMap.entries()].map(([id, data]) => {
|
||||
const personAssignments = normalizedAssignments.filter(
|
||||
(assignment) => assignment.resource?.id === id,
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
name: data.name,
|
||||
shortCode: data.chapter ?? "",
|
||||
allocatedHours: Math.round(data.allocatedHours),
|
||||
requiredFTEs: 0,
|
||||
resourceCount: data.projectIds.size,
|
||||
derivation: {
|
||||
periodStart: toIsoDate(input.startDate),
|
||||
periodEnd: toIsoDate(input.endDate),
|
||||
periodWorkingHoursBase,
|
||||
requiredHours: null,
|
||||
requiredFTEs: 0,
|
||||
fillPct: null,
|
||||
demandSource: "NONE",
|
||||
calendarLocations: summarizeCalendarLocations(
|
||||
personAssignments,
|
||||
resourceInfoById,
|
||||
contexts,
|
||||
input,
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user