feat(planning): ship holiday-aware planning and assistant upgrades

This commit is contained in:
2026-03-28 22:49:28 +01:00
parent 2a005794e7
commit 4f48afe7b4
151 changed files with 17738 additions and 1940 deletions
@@ -1,9 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import {
getDashboardBudgetForecast,
getDashboardChargeabilityOverview,
getDashboardDemand,
getDashboardOverview,
getDashboardPeakTimes,
getDashboardProjectHealth,
getDashboardTopValueResources,
} from "../index.js";
@@ -285,8 +287,28 @@ describe("dashboard use-cases", () => {
},
resource: {
findMany: vi.fn().mockResolvedValue([
{ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 } },
{ availability: { monday: 6, tuesday: 6, wednesday: 6, thursday: 6, friday: 6 } },
{
id: "res_1",
displayName: "Alice",
chapter: "CGI",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: null,
metroCityId: "city_1",
country: { code: "DE" },
metroCity: { name: "Munich" },
},
{
id: "res_2",
displayName: "Bob",
chapter: "Lighting",
availability: { monday: 6, tuesday: 6, wednesday: 6, thursday: 6, friday: 6 },
countryId: "country_de",
federalState: null,
metroCityId: "city_2",
country: { code: "DE" },
metroCity: { name: "Hamburg" },
},
]),
},
};
@@ -299,15 +321,113 @@ describe("dashboard use-cases", () => {
});
expect(result).toEqual([
{
expect.objectContaining({
period: "2026-03",
groups: [
{ name: "ALPHA", hours: 8 },
{ name: "ALPHA", hours: 4 },
{ name: "BRAVO", hours: 3 },
],
totalHours: 11,
capacityHours: 308,
totalHours: 7,
capacityHours: 28,
derivation: expect.objectContaining({
bookedHours: 7,
capacityHours: 28,
remainingCapacityHours: 21,
overbookedHours: 0,
utilizationPct: 25,
groupCount: 2,
resourceCount: 2,
}),
}),
]);
});
it("provides department capacity and utilization details for chapter grouping", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assign_1",
projectId: "proj_1",
resourceId: "res_1",
status: "ACTIVE",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-02T00:00:00.000Z"),
hoursPerDay: 8,
dailyCostCents: 0,
project: { id: "proj_1", name: "Alpha", shortCode: "ALPHA", status: "ACTIVE", orderType: "FIXED" },
resource: { id: "res_1", displayName: "Alice", chapter: "CGI" },
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
displayName: "Alice",
chapter: "CGI",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: null,
metroCityId: "city_1",
country: { code: "DE" },
metroCity: { name: "Munich" },
},
{
id: "res_2",
displayName: "Bob",
chapter: "Lighting",
availability: { monday: 4, tuesday: 4, wednesday: 4, thursday: 4, friday: 4 },
countryId: "country_de",
federalState: null,
metroCityId: "city_2",
country: { code: "DE" },
metroCity: { name: "Hamburg" },
},
]),
},
};
const result = await getDashboardPeakTimes(db as never, {
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-02T00:00:00.000Z"),
granularity: "month",
groupBy: "chapter",
});
expect(result).toEqual([
expect.objectContaining({
period: "2026-03",
groups: [
expect.objectContaining({
name: "CGI",
hours: 8,
capacityHours: 8,
remainingHours: 0,
overbookedHours: 0,
utilizationPct: 100,
}),
expect.objectContaining({
name: "Lighting",
hours: 0,
capacityHours: 4,
remainingHours: 4,
overbookedHours: 0,
utilizationPct: 0,
}),
],
totalHours: 8,
capacityHours: 12,
derivation: expect.objectContaining({
bookedHours: 8,
capacityHours: 12,
remainingCapacityHours: 4,
overbookedHours: 0,
utilizationPct: 67,
groupCount: 2,
resourceCount: 2,
}),
}),
]);
});
@@ -513,6 +633,396 @@ describe("dashboard use-cases", () => {
expect(withProposed.top[0]?.expectedChargeability).toBe(5);
});
it("excludes regional public holidays from dashboard chargeability availability and bookings", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assign_holiday",
projectId: "proj_1",
resourceId: "res_by",
status: "CONFIRMED",
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
hoursPerDay: 8,
dailyCostCents: 0,
project: {
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: {
id: "res_by",
displayName: "Bruce",
chapter: "CGI",
},
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_by",
eid: "bruce.banner",
displayName: "Bruce",
chapter: "CGI",
countryId: "country_de",
federalState: "BY",
metroCityId: "city_augsburg",
departed: false,
chargeabilityTarget: 80,
country: {
id: "country_de",
code: "DE",
},
metroCity: {
id: "city_augsburg",
name: "Augsburg",
},
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
},
]),
},
};
const result = await getDashboardChargeabilityOverview(db as never, {
now: new Date("2026-01-15T00:00:00.000Z"),
topN: 10,
watchlistThreshold: 15,
});
expect(result.top[0]?.actualChargeability).toBe(0);
expect(result.top[0]?.expectedChargeability).toBe(0);
expect(result.top[0]).toEqual(
expect.objectContaining({
countryCode: "DE",
federalState: "BY",
metroCityName: "Augsburg",
derivation: expect.objectContaining({
weeklyAvailabilityHours: 40,
baseAvailableHours: 184,
effectiveAvailableHours: 168,
publicHolidayCount: 2,
publicHolidayWorkdayCount: 2,
publicHolidayHoursDeduction: 16,
absenceHoursDeduction: 0,
actualBookedHours: 0,
expectedBookedHours: 0,
targetBookedHours: 134.4,
unassignedHours: 168,
}),
}),
);
});
it("uses holiday-aware capacity in peak times for regional calendars", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assign_1",
projectId: "proj_1",
resourceId: "res_by",
status: "CONFIRMED",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
hoursPerDay: 8,
dailyCostCents: 0,
project: { id: "proj_1", name: "Alpha", shortCode: "ALPHA", status: "ACTIVE", orderType: "FIXED" },
resource: { id: "res_by", displayName: "Bruce", chapter: "CGI" },
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_by",
displayName: "Bruce",
chapter: "CGI",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: "city_munich",
country: { code: "DE" },
metroCity: { name: "Munich" },
},
]),
},
};
const result = await getDashboardPeakTimes(db as never, {
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
granularity: "month",
groupBy: "project",
});
expect(result).toEqual([
expect.objectContaining({
period: "2026-01",
groups: [{ name: "ALPHA", hours: 8 }],
totalHours: 8,
capacityHours: 8,
derivation: expect.objectContaining({
bookedHours: 8,
capacityHours: 8,
remainingCapacityHours: 0,
overbookedHours: 0,
utilizationPct: 100,
groupCount: 1,
resourceCount: 1,
}),
}),
]);
});
it("does not burn budget on regional public holidays", async () => {
const db = {
project: {
findMany: vi.fn().mockResolvedValue([
{
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
budgetCents: 10_000,
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-12-31T00:00:00.000Z"),
clientId: null,
client: null,
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
projectId: "proj_1",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
dailyCostCents: 1_000,
resource: {
id: "res_by",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: "city_munich",
country: { code: "DE" },
metroCity: { name: "Munich" },
},
},
]),
},
};
const result = await getDashboardBudgetForecast(db as never);
expect(result).toEqual([
expect.objectContaining({
projectId: "proj_1",
shortCode: "ALPHA",
spentCents: 1_000,
remainingCents: 9_000,
activeAssignmentCount: 0,
calendarLocations: [],
}),
]);
});
it("returns burn derivation and calendar basis for active project forecasts", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-15T00:00:00.000Z"));
try {
const db = {
project: {
findMany: vi.fn().mockResolvedValue([
{
id: "proj_1",
name: "Gelddruckmaschine",
shortCode: "GDM",
budgetCents: 100_000,
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-12-31T00:00:00.000Z"),
clientId: "client_1",
client: { name: "ACME" },
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
projectId: "proj_1",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
dailyCostCents: 1_000,
resource: {
id: "res_by",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: "city_munich",
country: { code: "DE", name: "Deutschland" },
metroCity: { name: "Munich" },
},
},
{
projectId: "proj_1",
startDate: new Date("2026-01-15T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
dailyCostCents: 2_000,
resource: {
id: "res_hh",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "HH",
metroCityId: "city_hamburg",
country: { code: "DE", name: "Deutschland" },
metroCity: { name: "Hamburg" },
},
},
]),
},
};
const result = await getDashboardBudgetForecast(db as never);
expect(result).toEqual([
expect.objectContaining({
projectId: "proj_1",
shortCode: "GDM",
budgetCents: 100_000,
spentCents: 5_000,
remainingCents: 95_000,
burnRate: 4_000,
activeAssignmentCount: 1,
calendarLocations: [
expect.objectContaining({
countryCode: "DE",
countryName: "Deutschland",
federalState: "HH",
metroCityName: "Hamburg",
activeAssignmentCount: 1,
burnRateCents: 4_000,
}),
],
}),
]);
} finally {
vi.useRealTimers();
}
});
it("excludes regional public holidays from overview budget totals", async () => {
const db = {
assignment: {
findMany: vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
dailyCostCents: 1_000,
resource: {
id: "res_by",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: "city_munich",
country: { code: "DE" },
metroCity: { name: "Munich" },
},
},
]),
},
resource: {
count: vi
.fn()
.mockResolvedValueOnce(1)
.mockResolvedValueOnce(1),
findMany: vi.fn().mockResolvedValue([{ chapter: "CGI", chargeabilityTarget: 80 }]),
},
project: {
count: vi.fn().mockResolvedValue(1),
findMany: vi.fn().mockResolvedValue([{ status: "ACTIVE", budgetCents: 10_000 }]),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const result = await getDashboardOverview(db as never);
expect(result.budgetSummary).toEqual({
totalBudgetCents: 10_000,
totalCostCents: 1_000,
avgUtilizationPercent: 10,
});
});
it("excludes regional public holidays from project health budget usage", async () => {
const db = {
project: {
findMany: vi.fn().mockResolvedValue([
{
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
budgetCents: 10_000,
endDate: new Date("2026-12-31T00:00:00.000Z"),
clientId: null,
client: null,
demandRequirements: [],
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
projectId: "proj_1",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
dailyCostCents: 1_000,
resource: {
id: "res_by",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: "city_munich",
country: { code: "DE" },
metroCity: { name: "Munich" },
},
},
]),
},
};
const result = await getDashboardProjectHealth(db as never);
expect(result).toEqual([
expect.objectContaining({
shortCode: "ALPHA",
budgetHealth: 90,
spentCents: 1_000,
budgetUtilizationPercent: 10,
calendarLocations: [
expect.objectContaining({
countryCode: "DE",
federalState: "BY",
metroCityName: "Munich",
assignmentCount: 1,
spentCents: 1_000,
}),
],
}),
]);
});
it("returns distinct resource counts for chapter demand grouping", async () => {
const db = {
demandRequirement: {
@@ -606,14 +1116,21 @@ describe("dashboard use-cases", () => {
});
expect(result).toEqual([
{
expect.objectContaining({
id: "CGI",
name: "CGI",
shortCode: "CGI",
allocatedHours: 19,
requiredFTEs: 0,
resourceCount: 2,
},
derivation: expect.objectContaining({
periodWorkingHoursBase: 176,
requiredHours: null,
fillPct: null,
demandSource: "NONE",
calendarLocations: [],
}),
}),
]);
});
@@ -738,14 +1255,21 @@ describe("dashboard use-cases", () => {
});
expect(result).toEqual([
{
expect.objectContaining({
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
allocatedHours: 18,
requiredFTEs: 3.5,
resourceCount: 3,
},
derivation: expect.objectContaining({
periodWorkingHoursBase: 176,
requiredHours: 616,
fillPct: 3,
demandSource: "DEMAND_REQUIREMENTS",
calendarLocations: [],
}),
}),
]);
});
@@ -802,14 +1326,20 @@ describe("dashboard use-cases", () => {
});
expect(result).toEqual([
{
expect.objectContaining({
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
allocatedHours: 8,
requiredFTEs: 2,
resourceCount: 1,
},
derivation: expect.objectContaining({
periodWorkingHoursBase: 176,
requiredHours: 352,
fillPct: 2,
demandSource: "DEMAND_REQUIREMENTS",
}),
}),
]);
});
@@ -865,14 +1395,20 @@ describe("dashboard use-cases", () => {
});
expect(result).toEqual([
{
expect.objectContaining({
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
allocatedHours: 16,
requiredFTEs: 2,
resourceCount: 1,
},
derivation: expect.objectContaining({
periodWorkingHoursBase: 176,
requiredHours: 352,
fillPct: 5,
demandSource: "PROJECT_STAFFING_REQS",
}),
}),
]);
});
});
@@ -270,6 +270,7 @@ describe("demand and assignment use-cases", () => {
const db = {
demandRequirement: { findUnique: demandRequirementFindUnique },
assignment: { findMany: assignmentFindMany },
resource: { findUnique: resourceFindUnique },
$transaction: vi.fn(async (callback) =>
callback({
@@ -437,6 +438,7 @@ describe("demand and assignment use-cases", () => {
const db = {
demandRequirement: { findUnique: demandRequirementFindUnique },
assignment: { findMany: assignmentFindMany },
resource: { findUnique: resourceFindUnique },
$transaction: vi.fn(async (callback) =>
callback({
@@ -555,6 +557,7 @@ describe("demand and assignment use-cases", () => {
const db = {
demandRequirement: { findUnique: demandRequirementFindUnique },
assignment: { findMany: assignmentFindMany },
$transaction: vi.fn(async (callback) =>
callback({
project: { findUnique: projectFindUnique },
@@ -685,7 +688,7 @@ describe("demand and assignment use-cases", () => {
const db = {
demandRequirement: { findUnique: demandRequirementFindUnique },
assignment: { findUnique: assignmentFindUnique },
assignment: { findUnique: assignmentFindUnique, findMany: assignmentFindMany },
resource: { findUnique: resourceFindUnique },
$transaction: vi.fn(async (callback) =>
callback({
@@ -805,7 +808,7 @@ describe("demand and assignment use-cases", () => {
findMany: allocationFindMany,
},
demandRequirement: { findUnique: demandRequirementFindUnique },
assignment: { findUnique: assignmentFindUnique },
assignment: { findUnique: assignmentFindUnique, findMany: assignmentFindMany },
resource: { findUnique: resourceFindUnique },
$transaction: vi.fn(async (callback) =>
callback({
@@ -966,7 +969,7 @@ describe("demand and assignment use-cases", () => {
findMany: allocationFindMany,
},
demandRequirement: { findUnique: demandRequirementFindUnique },
assignment: { findUnique: assignmentFindUnique },
assignment: { findUnique: assignmentFindUnique, findMany: assignmentFindMany },
resource: { findUnique: resourceFindUnique },
$transaction: vi.fn(async (callback) =>
callback({
@@ -1102,7 +1105,7 @@ describe("demand and assignment use-cases", () => {
demandRequirement: {
findUnique: demandRequirementFindUnique,
},
assignment: { findUnique: assignmentFindUnique },
assignment: { findUnique: assignmentFindUnique, findMany: assignmentFindMany },
auditLog: { create: auditLogCreate },
$transaction: vi.fn(async (callback) =>
callback({
@@ -1227,7 +1230,7 @@ describe("demand and assignment use-cases", () => {
demandRequirement: {
findUnique: demandRequirementFindUnique,
},
assignment: { findUnique: assignmentFindUnique },
assignment: { findUnique: assignmentFindUnique, findMany: assignmentFindMany },
auditLog: { create: auditLogCreate },
$transaction: vi.fn(async (callback) =>
callback({
+8
View File
@@ -80,8 +80,16 @@ export {
type GetDashboardTopValueResourcesInput,
type GetDashboardDemandInput,
type GetDashboardChargeabilityOverviewInput,
type DashboardChargeabilityDerivation,
type DashboardChargeabilityRow,
getDashboardBudgetForecast,
type BudgetForecastRow,
type BudgetForecastLocationSummary,
type PeakTimesPeriodDerivation,
type PeakTimesPeriodRow,
type DemandCalendarLocationSummary,
type DemandRowDerivation,
type DashboardDemandRow,
getDashboardSkillGaps,
type SkillGapRow,
getDashboardProjectHealth,
@@ -1,16 +1,57 @@
import type { PrismaClient } from "@capakraken/db";
import type { WeekdayAvailability } from "@capakraken/shared";
import { calculateInclusiveDays, MILLISECONDS_PER_DAY } from "./shared.js";
import {
calculateEffectiveAllocationCostCents,
loadDailyAvailabilityContexts,
} from "./holiday-capacity.js";
export interface BudgetForecastLocationSummary {
countryCode: string | null;
countryName: string | null;
federalState: string | null;
metroCityName: string | null;
activeAssignmentCount: number;
burnRateCents: number;
}
export interface BudgetForecastRow {
projectId?: string;
projectName: string;
shortCode: string;
clientId: string | null;
clientName: string | null;
budgetCents: number;
spentCents: number;
remainingCents?: number;
burnRate: number;
estimatedExhaustionDate: string | null;
pctUsed: number;
activeAssignmentCount?: number;
calendarLocations?: BudgetForecastLocationSummary[];
}
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 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,
});
}
export async function getDashboardBudgetForecast(
@@ -32,7 +73,7 @@ export async function getDashboardBudgetForecast(
if (projects.length === 0) return [];
const projectIds = projects.map((p) => p.id);
const projectIds = projects.map((project) => project.id);
const assignments = await db.assignment.findMany({
where: {
@@ -44,42 +85,142 @@ export async function getDashboardBudgetForecast(
startDate: true,
endDate: true,
dailyCostCents: true,
resource: {
select: {
id: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: {
select: {
code: true,
name: true,
},
},
metroCity: {
select: {
name: true,
},
},
},
},
},
});
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const contextStart = assignments.length > 0
? new Date(
Math.min(
...assignments.map((assignment) => assignment.startDate.getTime()),
monthStart.getTime(),
),
)
: monthStart;
const contextEnd = assignments.length > 0
? new Date(
Math.max(
...assignments.map((assignment) => assignment.endDate.getTime()),
monthEnd.getTime(),
),
)
: monthEnd;
const contexts = await loadDailyAvailabilityContexts(
db,
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,
})),
contextStart,
contextEnd,
);
const spentByProject = new Map<string, number>();
const monthlyBurnByProject = new Map<string, number>();
const activeAssignmentCountByProject = new Map<string, number>();
const activeLocationsByProject = new Map<string, Map<string, BudgetForecastLocationSummary>>();
for (const a of assignments) {
const days = calculateInclusiveDays(a.startDate, a.endDate);
const totalCost = (a.dailyCostCents ?? 0) * days;
for (const assignment of assignments) {
const totalCost = hasAvailability(assignment.resource)
? calculateEffectiveAllocationCostCents({
availability: assignment.resource.availability as unknown as WeekdayAvailability,
startDate: assignment.startDate,
endDate: assignment.endDate,
dailyCostCents: assignment.dailyCostCents ?? 0,
periodStart: assignment.startDate,
periodEnd: assignment.endDate,
context: contexts.get(assignment.resource.id),
})
: (assignment.dailyCostCents ?? 0)
* calculateInclusiveDays(assignment.startDate, assignment.endDate);
spentByProject.set(
a.projectId,
(spentByProject.get(a.projectId) ?? 0) + totalCost,
assignment.projectId,
(spentByProject.get(assignment.projectId) ?? 0) + totalCost,
);
// Approximate monthly burn from active assignments that overlap today
if (a.startDate <= now && a.endDate >= now) {
// ~22 working days per month
const monthlyContribution = (a.dailyCostCents ?? 0) * 22;
if (assignment.startDate <= now && assignment.endDate >= now) {
const monthlyContribution = hasAvailability(assignment.resource)
? calculateEffectiveAllocationCostCents({
availability: assignment.resource.availability as unknown as WeekdayAvailability,
startDate: assignment.startDate,
endDate: assignment.endDate,
dailyCostCents: assignment.dailyCostCents ?? 0,
periodStart: monthStart,
periodEnd: monthEnd,
context: contexts.get(assignment.resource.id),
})
: (assignment.dailyCostCents ?? 0) * 22;
monthlyBurnByProject.set(
a.projectId,
(monthlyBurnByProject.get(a.projectId) ?? 0) + monthlyContribution,
assignment.projectId,
(monthlyBurnByProject.get(assignment.projectId) ?? 0) + monthlyContribution,
);
activeAssignmentCountByProject.set(
assignment.projectId,
(activeAssignmentCountByProject.get(assignment.projectId) ?? 0) + 1,
);
const locationSummaries = activeLocationsByProject.get(assignment.projectId) ?? new Map();
const locationKey = buildLocationKey({
countryCode: assignment.resource.country?.code,
countryName: assignment.resource.country?.name,
federalState: assignment.resource.federalState,
metroCityName: assignment.resource.metroCity?.name,
});
const summary = locationSummaries.get(locationKey) ?? {
countryCode: assignment.resource.country?.code ?? null,
countryName: assignment.resource.country?.name ?? null,
federalState: assignment.resource.federalState ?? null,
metroCityName: assignment.resource.metroCity?.name ?? null,
activeAssignmentCount: 0,
burnRateCents: 0,
};
summary.activeAssignmentCount += 1;
summary.burnRateCents += monthlyContribution;
locationSummaries.set(locationKey, summary);
activeLocationsByProject.set(assignment.projectId, locationSummaries);
}
}
const rows: BudgetForecastRow[] = projects.map((p) => {
const spentCents = spentByProject.get(p.id) ?? 0;
const burnRate = monthlyBurnByProject.get(p.id) ?? 0;
const pctUsed =
p.budgetCents > 0 ? Math.round((spentCents / p.budgetCents) * 100) : 0;
const rows: BudgetForecastRow[] = projects.map((project) => {
const spentCents = spentByProject.get(project.id) ?? 0;
const burnRate = monthlyBurnByProject.get(project.id) ?? 0;
const remainingCents = Math.max(0, project.budgetCents - spentCents);
const pctUsed = project.budgetCents > 0
? Math.round((spentCents / project.budgetCents) * 100)
: 0;
let estimatedExhaustionDate: string | null = null;
if (burnRate > 0 && p.budgetCents > spentCents) {
const remainingCents = p.budgetCents - spentCents;
if (burnRate > 0 && project.budgetCents > spentCents) {
const monthsRemaining = remainingCents / burnRate;
const exhaustionDate = new Date(
now.getTime() + monthsRemaining * 30 * MILLISECONDS_PER_DAY,
@@ -88,18 +229,23 @@ export async function getDashboardBudgetForecast(
}
return {
projectName: p.name,
shortCode: p.shortCode,
clientId: p.clientId,
clientName: p.client?.name ?? null,
budgetCents: p.budgetCents,
projectId: project.id,
projectName: project.name,
shortCode: project.shortCode,
clientId: project.clientId,
clientName: project.client?.name ?? null,
budgetCents: project.budgetCents,
spentCents,
remainingCents,
burnRate,
estimatedExhaustionDate,
pctUsed,
activeAssignmentCount: activeAssignmentCountByProject.get(project.id) ?? 0,
calendarLocations: Array.from(activeLocationsByProject.get(project.id)?.values() ?? [])
.sort((left, right) => right.burnRateCents - left.burnRateCents),
};
});
rows.sort((a, b) => b.pctUsed - a.pctUsed);
rows.sort((left, right) => right.pctUsed - left.pctUsed);
return rows;
}
@@ -1,11 +1,26 @@
import type { PrismaClient } from "@capakraken/db";
import { computeChargeability } from "@capakraken/engine";
import type { WeekdayAvailability } from "@capakraken/shared";
import {
isChargeabilityActualBooking,
isChargeabilityRelevantProject,
} from "../allocation/chargeability-bookings.js";
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
import {
calculateEffectiveAllocationHours,
calculateEffectiveAvailableHours,
loadDailyAvailabilityContexts,
type DailyAvailabilityContext,
} from "./holiday-capacity.js";
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
export interface GetDashboardChargeabilityOverviewInput {
includeProposed?: boolean;
@@ -16,6 +31,132 @@ export interface GetDashboardChargeabilityOverviewInput {
now?: Date;
}
export interface DashboardChargeabilityDerivation {
weeklyAvailabilityHours: number;
baseWorkingDays: number;
effectiveWorkingDayEquivalent: number;
baseAvailableHours: number;
effectiveAvailableHours: number;
publicHolidayCount: number;
publicHolidayWorkdayCount: number;
publicHolidayHoursDeduction: number;
absenceDayEquivalent: number;
absenceHoursDeduction: number;
actualBookedHours: number;
expectedBookedHours: number;
targetBookedHours: number;
unassignedHours: number;
}
export interface DashboardChargeabilityRow {
id: string;
eid: string;
displayName: string;
chapter: string | null;
countryId?: string | null;
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityName?: string | null;
departed: boolean | null;
chargeabilityTarget: number;
actualChargeability: number;
expectedChargeability: number;
derivation?: DashboardChargeabilityDerivation;
}
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function getDailyAvailabilityHours(
availability: WeekdayAvailability,
date: Date,
): number {
const dayKey = DAY_KEYS[date.getUTCDay()];
return dayKey ? (availability[dayKey] ?? 0) : 0;
}
function summarizeDerivation(
availability: WeekdayAvailability,
periodStart: Date,
periodEnd: Date,
context: DailyAvailabilityContext | undefined,
actualBookedHours: number,
expectedBookedHours: number,
chargeabilityTarget: number,
): DashboardChargeabilityDerivation {
let baseWorkingDays = 0;
let effectiveWorkingDayEquivalent = 0;
let publicHolidayWorkdayCount = 0;
let publicHolidayHoursDeduction = 0;
let absenceDayEquivalent = 0;
let absenceHoursDeduction = 0;
const weeklyAvailabilityHours = Object.values(availability).reduce(
(sum, hours) => sum + (hours ?? 0),
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) {
baseWorkingDays += 1;
if (isHoliday) {
publicHolidayWorkdayCount += 1;
publicHolidayHoursDeduction += baseHours;
} else {
absenceDayEquivalent += absenceFraction;
absenceHoursDeduction += baseHours * absenceFraction;
effectiveWorkingDayEquivalent += Math.max(0, 1 - absenceFraction);
}
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return {
weeklyAvailabilityHours,
baseWorkingDays,
effectiveWorkingDayEquivalent,
baseAvailableHours,
effectiveAvailableHours,
publicHolidayCount: context?.holidayDates.size ?? 0,
publicHolidayWorkdayCount,
publicHolidayHoursDeduction,
absenceDayEquivalent,
absenceHoursDeduction,
actualBookedHours,
expectedBookedHours,
targetBookedHours: Math.round((effectiveAvailableHours * chargeabilityTarget) / 10) / 10,
unassignedHours: Math.max(0, effectiveAvailableHours - expectedBookedHours),
};
}
export async function getDashboardChargeabilityOverview(
db: PrismaClient,
input: GetDashboardChargeabilityOverviewInput,
@@ -38,8 +179,23 @@ export async function getDashboardChargeabilityOverview(
displayName: true,
chapter: true,
countryId: true,
federalState: true,
metroCityId: true,
departed: true,
chargeabilityTarget: true,
country: {
select: {
id: true,
code: true,
name: true,
},
},
metroCity: {
select: {
id: true,
name: true,
},
},
availability: true,
},
});
@@ -48,9 +204,24 @@ export async function getDashboardChargeabilityOverview(
endDate: end,
resourceIds: resources.map((resource) => resource.id),
});
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,
})),
start,
end,
);
const stats = resources.map((resource) => {
const stats: DashboardChargeabilityRow[] = resources.map((resource) => {
const availability = resource.availability as unknown as WeekdayAvailability;
const context = contexts.get(resource.id);
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
const actualAllocations = resourceBookings.filter((booking) =>
isChargeabilityActualBooking(booking, input.includeProposed === true),
@@ -58,18 +229,43 @@ export async function getDashboardChargeabilityOverview(
const expectedAllocations = resourceBookings.filter(
(booking) => isChargeabilityRelevantProject(booking.project, true),
);
const actual = computeChargeability(
const availableHours = calculateEffectiveAvailableHours({
availability,
actualAllocations,
start,
end,
periodStart: start,
periodEnd: end,
context,
});
const actualBookedHours = actualAllocations.reduce(
(sum, allocation) => sum + calculateEffectiveAllocationHours({
availability,
startDate: allocation.startDate,
endDate: allocation.endDate,
hoursPerDay: allocation.hoursPerDay,
periodStart: start,
periodEnd: end,
context,
}),
0,
);
const expected = computeChargeability(
availability,
expectedAllocations,
start,
end,
const expectedBookedHours = expectedAllocations.reduce(
(sum, allocation) => sum + calculateEffectiveAllocationHours({
availability,
startDate: allocation.startDate,
endDate: allocation.endDate,
hoursPerDay: allocation.hoursPerDay,
periodStart: start,
periodEnd: end,
context,
}),
0,
);
const actualChargeability = availableHours > 0
? Math.min(100, Math.round((actualBookedHours / availableHours) * 100))
: 0;
const expectedChargeability = availableHours > 0
? Math.min(100, Math.round((expectedBookedHours / availableHours) * 100))
: 0;
const chargeabilityTarget = resource.chargeabilityTarget ?? 0;
return {
id: resource.id,
@@ -77,10 +273,23 @@ export async function getDashboardChargeabilityOverview(
displayName: resource.displayName,
chapter: resource.chapter,
countryId: resource.countryId,
countryCode: resource.country?.code ?? null,
countryName: resource.country?.name ?? null,
federalState: resource.federalState ?? null,
metroCityName: resource.metroCity?.name ?? null,
departed: resource.departed,
chargeabilityTarget: resource.chargeabilityTarget,
actualChargeability: actual.chargeability,
expectedChargeability: expected.chargeability,
chargeabilityTarget,
actualChargeability,
expectedChargeability,
derivation: summarizeDerivation(
availability,
start,
end,
context,
actualBookedHours,
expectedBookedHours,
chargeabilityTarget,
),
};
});
@@ -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,
),
},
};
});
}
@@ -1,8 +1,18 @@
import type { PrismaClient } from "@capakraken/db";
import { AllocationStatus } from "@capakraken/shared";
import { buildSplitAllocationReadModel } from "../allocation/build-split-allocation-read-model.js";
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
import { calculateInclusiveDays } from "./shared.js";
import type { WeekdayAvailability } from "@capakraken/shared";
import {
calculateEffectiveAllocationCostCents,
loadDailyAvailabilityContexts,
} from "./holiday-capacity.js";
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;
}
export async function getDashboardOverview(db: PrismaClient) {
const [
@@ -12,7 +22,7 @@ export async function getDashboardOverview(db: PrismaClient) {
allProjects,
allDemandRequirements,
allAssignments,
budgetBookings,
budgetAssignments,
recentActivity,
allResources,
] = await Promise.all([
@@ -58,7 +68,25 @@ export async function getDashboardOverview(db: PrismaClient) {
updatedAt: true,
},
}),
listAssignmentBookings(db, {}),
db.assignment.findMany({
where: { status: { not: "CANCELLED" } },
select: {
startDate: true,
endDate: true,
dailyCostCents: true,
resource: {
select: {
id: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
},
},
}),
db.auditLog.findMany({
orderBy: { createdAt: "desc" },
take: 10,
@@ -77,12 +105,46 @@ export async function getDashboardOverview(db: PrismaClient) {
const activeAllocations = planningReadModel.allocations.filter(
(allocation) => allocation.status !== AllocationStatus.CANCELLED,
).length;
const contextStart = budgetAssignments.length > 0
? new Date(Math.min(...budgetAssignments.map((assignment) => assignment.startDate.getTime())))
: new Date();
const contextEnd = budgetAssignments.length > 0
? new Date(Math.max(...budgetAssignments.map((assignment) => assignment.endDate.getTime())))
: new Date();
const contexts = await loadDailyAvailabilityContexts(
db,
budgetAssignments
.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,
})),
contextStart,
contextEnd,
);
const totalCostCents = budgetBookings.reduce(
(sum, booking) =>
sum +
(booking.dailyCostCents ?? 0) *
calculateInclusiveDays(booking.startDate, booking.endDate),
const totalCostCents = budgetAssignments.reduce(
(sum, assignment) =>
sum + (
hasAvailability(assignment.resource)
? calculateEffectiveAllocationCostCents({
availability: assignment.resource.availability as unknown as WeekdayAvailability,
startDate: assignment.startDate,
endDate: assignment.endDate,
dailyCostCents: assignment.dailyCostCents ?? 0,
periodStart: assignment.startDate,
periodEnd: assignment.endDate,
context: contexts.get(assignment.resource.id),
})
: (assignment.dailyCostCents ?? 0) *
calculateInclusiveDays(assignment.startDate, assignment.endDate)
),
0,
);
@@ -90,6 +152,12 @@ export async function getDashboardOverview(db: PrismaClient) {
(sum, project) => sum + (project.budgetCents ?? 0),
0,
);
const activeProjectCount = allProjects.filter((project) => project.status === "ACTIVE").length;
const inactiveResourceCount = Math.max(totalResources - activeResources, 0);
const inactiveProjectCount = Math.max(totalProjects - activeProjectCount, 0);
const cancelledAllocations = Math.max(totalAllocations - activeAllocations, 0);
const budgetedProjects = allProjects.filter((project) => (project.budgetCents ?? 0) > 0).length;
const remainingBudgetCents = totalBudgetCents - totalCostCents;
const avgUtilizationPercent =
totalBudgetCents > 0
@@ -125,16 +193,26 @@ export async function getDashboardOverview(db: PrismaClient) {
return {
totalResources,
activeResources,
inactiveResources: inactiveResourceCount,
totalProjects,
activeProjects: allProjects.filter((project) => project.status === "ACTIVE")
.length,
activeProjects: activeProjectCount,
inactiveProjects: inactiveProjectCount,
totalAllocations,
activeAllocations,
cancelledAllocations,
budgetSummary: {
totalBudgetCents,
totalCostCents,
avgUtilizationPercent,
},
budgetBasis: {
remainingBudgetCents,
budgetedProjects,
unbudgetedProjects: Math.max(totalProjects - budgetedProjects, 0),
trackedAssignmentCount: budgetAssignments.length,
windowStart: budgetAssignments.length > 0 ? contextStart : null,
windowEnd: budgetAssignments.length > 0 ? contextEnd : null,
},
recentActivity: recentActivity.map((activity) => ({
id: activity.id,
entityType: activity.entityType,
@@ -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,
},
};
});
}
@@ -1,5 +1,10 @@
import type { PrismaClient } from "@capakraken/db";
import type { WeekdayAvailability } from "@capakraken/shared";
import { calculateInclusiveDays } from "./shared.js";
import {
calculateEffectiveAllocationCostCents,
loadDailyAvailabilityContexts,
} from "./holiday-capacity.js";
export interface ProjectHealthRow {
id: string;
@@ -11,6 +16,53 @@ export interface ProjectHealthRow {
staffingHealth: number;
timelineHealth: number;
compositeScore: number;
budgetCents?: number | null;
spentCents?: number;
remainingBudgetCents?: number | null;
budgetUtilizationPercent?: number | null;
demandHeadcountTotal?: number;
demandHeadcountFilled?: number;
demandHeadcountOpen?: number;
demandRequirementCount?: number;
plannedEndDate?: Date | null;
daysUntilEndDate?: number | null;
timelineStatus?: "ON_TRACK" | "DUE_SOON" | "OVERDUE" | "UNSCHEDULED";
calendarLocations?: Array<{
countryCode: string | null;
countryName: string | null;
federalState: string | null;
metroCityName: string | null;
assignmentCount: number;
spentCents: number;
}>;
}
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 toUtcDayStart(value: Date): Date {
return new Date(Date.UTC(
value.getUTCFullYear(),
value.getUTCMonth(),
value.getUTCDate(),
));
}
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,
});
}
export async function getDashboardProjectHealth(
@@ -55,14 +107,79 @@ export async function getDashboardProjectHealth(
startDate: true,
endDate: true,
dailyCostCents: true,
},
resource: {
select: {
id: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
},
},
},
});
const contextStart = assignments.length > 0
? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime())))
: new Date();
const contextEnd = assignments.length > 0
? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime())))
: new Date();
const contexts = await loadDailyAvailabilityContexts(
db,
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,
})),
contextStart,
contextEnd,
);
const spentByProject = new Map<string, number>();
const calendarLocationsByProject = new Map<string, Map<string, NonNullable<ProjectHealthRow["calendarLocations"]>[number]>>();
for (const a of assignments) {
const days = calculateInclusiveDays(a.startDate, a.endDate);
const cost = (a.dailyCostCents ?? 0) * days;
const cost = hasAvailability(a.resource)
? calculateEffectiveAllocationCostCents({
availability: a.resource.availability as unknown as WeekdayAvailability,
startDate: a.startDate,
endDate: a.endDate,
dailyCostCents: a.dailyCostCents ?? 0,
periodStart: a.startDate,
periodEnd: a.endDate,
context: contexts.get(a.resource.id),
})
: (a.dailyCostCents ?? 0) * calculateInclusiveDays(a.startDate, a.endDate);
spentByProject.set(a.projectId, (spentByProject.get(a.projectId) ?? 0) + cost);
if (a.resource) {
const projectLocations = calendarLocationsByProject.get(a.projectId) ?? new Map();
const locationKey = buildLocationKey({
countryCode: a.resource.country?.code,
countryName: a.resource.country?.name,
federalState: a.resource.federalState,
metroCityName: a.resource.metroCity?.name,
});
const summary = projectLocations.get(locationKey) ?? {
countryCode: a.resource.country?.code ?? null,
countryName: a.resource.country?.name ?? null,
federalState: a.resource.federalState ?? null,
metroCityName: a.resource.metroCity?.name ?? null,
assignmentCount: 0,
spentCents: 0,
};
summary.assignmentCount += 1;
summary.spentCents += cost;
projectLocations.set(locationKey, summary);
calendarLocationsByProject.set(a.projectId, projectLocations);
}
}
const now = new Date();
@@ -71,7 +188,7 @@ export async function getDashboardProjectHealth(
// Budget health: 100 - pctUsed (capped at 100)
const spentCents = spentByProject.get(p.id) ?? 0;
const pctUsed =
p.budgetCents > 0
(p.budgetCents ?? 0) > 0
? Math.round((spentCents / p.budgetCents) * 100)
: 0;
const budgetHealth = Math.max(0, 100 - Math.min(pctUsed, 100));
@@ -87,12 +204,29 @@ export async function getDashboardProjectHealth(
totalDemands > 0 ? Math.round((filledDemands / totalDemands) * 100) : 100;
// Timeline health: 100 if end date > today, else 0
const timelineHealth = p.endDate > now ? 100 : 0;
const endDate = p.endDate ? toUtcDayStart(p.endDate) : null;
const today = toUtcDayStart(now);
const daysUntilEndDate = endDate
? Math.round((endDate.getTime() - today.getTime()) / 86_400_000)
: null;
const timelineStatus = endDate === null
? "UNSCHEDULED"
: daysUntilEndDate! < 0
? "OVERDUE"
: daysUntilEndDate! <= 14
? "DUE_SOON"
: "ON_TRACK";
const timelineHealth = endDate === null
? 100
: endDate > today
? 100
: 0;
// Composite = average of 3 dimensions
const compositeScore = Math.round(
(budgetHealth + staffingHealth + timelineHealth) / 3,
);
const remainingBudgetCents = p.budgetCents == null ? null : p.budgetCents - spentCents;
return {
id: p.id,
@@ -104,6 +238,19 @@ export async function getDashboardProjectHealth(
staffingHealth,
timelineHealth,
compositeScore,
budgetCents: p.budgetCents ?? null,
spentCents,
remainingBudgetCents,
budgetUtilizationPercent: p.budgetCents && p.budgetCents > 0 ? pctUsed : null,
demandHeadcountTotal: totalDemands,
demandHeadcountFilled: filledDemands,
demandHeadcountOpen: Math.max(totalDemands - filledDemands, 0),
demandRequirementCount: p.demandRequirements.length,
plannedEndDate: p.endDate ?? null,
daysUntilEndDate,
timelineStatus,
calendarLocations: Array.from(calendarLocationsByProject.get(p.id)?.values() ?? [])
.sort((left, right) => right.spentCents - left.spentCents),
};
});
@@ -0,0 +1,459 @@
import { VacationStatus } from "@capakraken/db";
import { getPublicHolidays, type WeekdayAvailability } from "@capakraken/shared";
const MILLISECONDS_PER_DAY = 86_400_000;
type CalendarScope = "COUNTRY" | "STATE" | "CITY";
type HolidayCalendarEntryRecord = {
date: Date;
isRecurringAnnual: boolean;
};
type HolidayCalendarRecord = {
entries: HolidayCalendarEntryRecord[];
};
type VacationRecord = {
resourceId: string;
startDate: Date;
endDate: Date;
type: string;
isHalfDay: boolean;
};
type ResourceHolidayProfile = {
id: string;
availability: WeekdayAvailability;
countryId: string | null | undefined;
countryCode: string | null | undefined;
federalState: string | null | undefined;
metroCityId: string | null | undefined;
metroCityName: string | null | undefined;
};
type DashboardHolidayDbClient = {
holidayCalendar?: {
findMany: (args: {
where: Record<string, unknown>;
include: { entries: true };
orderBy: Array<Record<string, "asc" | "desc">>;
}) => Promise<unknown[]>;
};
vacation?: {
findMany: (args: {
where: Record<string, unknown>;
select: Record<string, boolean | Record<string, boolean>>;
}) => Promise<unknown[]>;
};
};
type DailyAvailabilityContext = {
holidayDates: Set<string>;
absenceFractionsByDate: Map<string, number>;
};
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
const CITY_HOLIDAY_RULES: Array<{
countryCode: string;
cityName: string;
resolveDates: (year: number) => string[];
}> = [
{
countryCode: "DE",
cityName: "Augsburg",
resolveDates: (year) => [`${year}-08-08`],
},
];
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function normalizeCityName(cityName?: string | null): string | null {
const normalized = cityName?.trim().toLowerCase();
return normalized && normalized.length > 0 ? normalized : null;
}
function normalizeStateCode(stateCode?: string | null): string | null {
const normalized = stateCode?.trim().toUpperCase();
return normalized && normalized.length > 0 ? normalized : null;
}
function getDailyAvailabilityHours(
availability: WeekdayAvailability,
date: Date,
): number {
const key = DAY_KEYS[date.getUTCDay()];
return key ? (availability[key] ?? 0) : 0;
}
function listBuiltinHolidayDates(input: {
periodStart: Date;
periodEnd: Date;
countryCode: string | null | undefined;
federalState: string | null | undefined;
metroCityName: string | null | undefined;
}): Set<string> {
const dates = new Set<string>();
const startIso = toIsoDate(input.periodStart);
const endIso = toIsoDate(input.periodEnd);
const startYear = input.periodStart.getUTCFullYear();
const endYear = input.periodEnd.getUTCFullYear();
if (input.countryCode === "DE") {
for (let year = startYear; year <= endYear; year += 1) {
for (const holiday of getPublicHolidays(year, input.federalState ?? undefined)) {
if (holiday.date >= startIso && holiday.date <= endIso) {
dates.add(holiday.date);
}
}
}
}
const normalizedCityName = normalizeCityName(input.metroCityName);
if (input.countryCode && normalizedCityName) {
for (const rule of CITY_HOLIDAY_RULES) {
if (
rule.countryCode === input.countryCode
&& normalizeCityName(rule.cityName) === normalizedCityName
) {
for (let year = startYear; year <= endYear; year += 1) {
for (const date of rule.resolveDates(year)) {
if (date >= startIso && date <= endIso) {
dates.add(date);
}
}
}
}
}
}
return dates;
}
function resolveCalendarEntryDates(
calendars: HolidayCalendarRecord[],
periodStart: Date,
periodEnd: Date,
): Set<string> {
const dates = new Set<string>();
const startIso = toIsoDate(periodStart);
const endIso = toIsoDate(periodEnd);
const startYear = periodStart.getUTCFullYear();
const endYear = periodEnd.getUTCFullYear();
for (const calendar of calendars) {
for (const entry of calendar.entries) {
const baseDate = new Date(entry.date);
for (let year = startYear; year <= endYear; year += 1) {
const effectiveDate = entry.isRecurringAnnual
? new Date(Date.UTC(year, baseDate.getUTCMonth(), baseDate.getUTCDate()))
: baseDate;
const isoDate = toIsoDate(effectiveDate);
if (isoDate >= startIso && isoDate <= endIso) {
dates.add(isoDate);
}
if (!entry.isRecurringAnnual) {
break;
}
}
}
}
return dates;
}
async function loadCustomHolidayDates(
db: DashboardHolidayDbClient,
input: {
periodStart: Date;
periodEnd: Date;
countryId: string | null | undefined;
federalState: string | null | undefined;
metroCityId: string | null | undefined;
},
): Promise<Set<string>> {
if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") {
return new Set();
}
const stateCode = normalizeStateCode(input.federalState);
const metroCityId = input.metroCityId?.trim() || null;
const calendars = await db.holidayCalendar.findMany({
where: {
isActive: true,
countryId: input.countryId,
OR: [
{ scopeType: "COUNTRY" as CalendarScope },
...(stateCode ? [{ scopeType: "STATE" as CalendarScope, stateCode }] : []),
...(metroCityId ? [{ scopeType: "CITY" as CalendarScope, metroCityId }] : []),
],
},
include: { entries: true },
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
});
return resolveCalendarEntryDates(
calendars as HolidayCalendarRecord[],
input.periodStart,
input.periodEnd,
);
}
function buildHolidayProfileKey(profile: ResourceHolidayProfile): string {
return JSON.stringify({
countryId: profile.countryId ?? null,
countryCode: profile.countryCode ?? null,
federalState: profile.federalState ?? null,
metroCityId: profile.metroCityId ?? null,
metroCityName: profile.metroCityName ?? null,
});
}
export async function loadDailyAvailabilityContexts(
db: DashboardHolidayDbClient,
resources: ResourceHolidayProfile[],
periodStart: Date,
periodEnd: Date,
): Promise<Map<string, DailyAvailabilityContext>> {
const profileHolidayCache = new Map<string, Promise<Set<string>>>();
const resourceIds = resources.map((resource) => resource.id);
const vacations = resourceIds.length > 0 && typeof db.vacation?.findMany === "function"
? await db.vacation.findMany({
where: {
resourceId: { in: resourceIds },
status: VacationStatus.APPROVED,
startDate: { lte: periodEnd },
endDate: { gte: periodStart },
},
select: {
resourceId: true,
startDate: true,
endDate: true,
type: true,
isHalfDay: true,
},
})
: [];
const vacationsByResourceId = new Map<string, VacationRecord[]>();
for (const vacation of vacations as VacationRecord[]) {
const items = vacationsByResourceId.get(vacation.resourceId) ?? [];
items.push(vacation);
vacationsByResourceId.set(vacation.resourceId, items);
}
const contexts = new Map<string, DailyAvailabilityContext>();
for (const resource of resources) {
const profileKey = buildHolidayProfileKey(resource);
const holidayPromise = profileHolidayCache.get(profileKey)
?? (async () => {
const builtin = listBuiltinHolidayDates({
periodStart,
periodEnd,
countryCode: resource.countryCode,
federalState: resource.federalState,
metroCityName: resource.metroCityName,
});
const custom = await loadCustomHolidayDates(db, {
periodStart,
periodEnd,
countryId: resource.countryId,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
});
return new Set([...builtin, ...custom]);
})();
if (!profileHolidayCache.has(profileKey)) {
profileHolidayCache.set(profileKey, holidayPromise);
}
const holidayDates = new Set(await holidayPromise);
const absenceFractionsByDate = new Map<string, number>();
const resourceVacations = vacationsByResourceId.get(resource.id) ?? [];
for (const vacation of resourceVacations) {
const overlapStart = new Date(
Math.max(vacation.startDate.getTime(), periodStart.getTime()),
);
const overlapEnd = new Date(
Math.min(vacation.endDate.getTime(), periodEnd.getTime()),
);
if (overlapStart > overlapEnd) {
continue;
}
const cursor = new Date(overlapStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(overlapEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const isoDate = toIsoDate(cursor);
const fraction = vacation.isHalfDay ? 0.5 : 1;
if (vacation.type === "PUBLIC_HOLIDAY") {
holidayDates.add(isoDate);
}
const existing = absenceFractionsByDate.get(isoDate) ?? 0;
if (vacation.type === "PUBLIC_HOLIDAY" || !holidayDates.has(isoDate)) {
absenceFractionsByDate.set(isoDate, Math.max(existing, fraction));
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
for (const isoDate of holidayDates) {
const existing = absenceFractionsByDate.get(isoDate) ?? 0;
absenceFractionsByDate.set(isoDate, Math.max(existing, 1));
}
contexts.set(resource.id, {
holidayDates,
absenceFractionsByDate,
});
}
return contexts;
}
function calculateDayAvailabilityFraction(
context: DailyAvailabilityContext | undefined,
isoDate: string,
): number {
const fraction = context?.absenceFractionsByDate.get(isoDate) ?? 0;
return Math.max(0, 1 - fraction);
}
export function calculateEffectiveAvailableHours(input: {
availability: WeekdayAvailability;
periodStart: Date;
periodEnd: Date;
context: DailyAvailabilityContext | undefined;
}): number {
let hours = 0;
const cursor = new Date(input.periodStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(input.periodEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const baseHours = getDailyAvailabilityHours(input.availability, cursor);
if (baseHours > 0) {
hours += baseHours * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return hours;
}
export function calculateEffectiveAllocationHours(input: {
availability: WeekdayAvailability;
startDate: Date;
endDate: Date;
hoursPerDay: number;
periodStart: Date;
periodEnd: Date;
context: DailyAvailabilityContext | undefined;
}): number {
let hours = 0;
const overlapStart = new Date(
Math.max(input.startDate.getTime(), input.periodStart.getTime()),
);
const overlapEnd = new Date(
Math.min(input.endDate.getTime(), input.periodEnd.getTime()),
);
if (overlapStart > overlapEnd) {
return 0;
}
const cursor = new Date(overlapStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(overlapEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const baseHours = getDailyAvailabilityHours(input.availability, cursor);
if (baseHours > 0) {
hours += input.hoursPerDay * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return hours;
}
export function calculateEffectiveAllocationCostCents(input: {
availability: WeekdayAvailability;
startDate: Date;
endDate: Date;
dailyCostCents: number;
periodStart: Date;
periodEnd: Date;
context: DailyAvailabilityContext | undefined;
}): number {
let costCents = 0;
const overlapStart = new Date(
Math.max(input.startDate.getTime(), input.periodStart.getTime()),
);
const overlapEnd = new Date(
Math.min(input.endDate.getTime(), input.periodEnd.getTime()),
);
if (overlapStart > overlapEnd) {
return 0;
}
const cursor = new Date(overlapStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(overlapEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const baseHours = getDailyAvailabilityHours(input.availability, cursor);
if (baseHours > 0) {
costCents += input.dailyCostCents * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return Math.round(costCents);
}
export function enumerateIsoDates(
periodStart: Date,
periodEnd: Date,
): string[] {
const dates: string[] = [];
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) {
dates.push(toIsoDate(cursor));
cursor.setTime(cursor.getTime() + MILLISECONDS_PER_DAY);
}
return dates;
}
export type { DailyAvailabilityContext, ResourceHolidayProfile };
@@ -5,6 +5,8 @@ export {
export {
getDashboardPeakTimes,
type GetDashboardPeakTimesInput,
type PeakTimesPeriodDerivation,
type PeakTimesPeriodRow,
} from "./get-peak-times.js";
export {
@@ -15,16 +17,22 @@ export {
export {
getDashboardDemand,
type GetDashboardDemandInput,
type DemandCalendarLocationSummary,
type DemandRowDerivation,
type DashboardDemandRow,
} from "./get-demand.js";
export {
getDashboardChargeabilityOverview,
type GetDashboardChargeabilityOverviewInput,
type DashboardChargeabilityDerivation,
type DashboardChargeabilityRow,
} from "./get-chargeability-overview.js";
export {
getDashboardBudgetForecast,
type BudgetForecastRow,
type BudgetForecastLocationSummary,
} from "./get-budget-forecast.js";
export {
@@ -18,6 +18,20 @@ export const DASHBOARD_PLANNING_ALLOCATION_INCLUDE = {
chapter: true,
eid: true,
lcrCents: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: {
select: {
code: true,
},
},
metroCity: {
select: {
name: true,
},
},
},
},
} as const;
@@ -1,4 +1,4 @@
import XLSX from "xlsx";
import * as XLSX from "xlsx";
export type WorksheetCellValue = boolean | Date | number | string | null;
export type WorksheetMatrix = WorksheetCellValue[][];
@@ -1,9 +1,14 @@
import type { PrismaClient } from "@capakraken/db";
import { computeValueScore } from "@capakraken/staffing";
import { VALUE_SCORE_WEIGHTS } from "@capakraken/shared";
import { VALUE_SCORE_WEIGHTS, type WeekdayAvailability } from "@capakraken/shared";
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
import {
calculateEffectiveAllocationHours,
calculateEffectiveAvailableHours,
loadDailyAvailabilityContexts,
} from "../dashboard/holiday-capacity.js";
type ResourceValueScoreDbClient = Partial<Pick<PrismaClient, "systemSettings">> &
type ResourceValueScoreDbClient = Partial<Pick<PrismaClient, "systemSettings" | "holidayCalendar" | "vacation">> &
Pick<PrismaClient, "assignment" | "resource" | "$transaction">;
export interface RecomputeResourceValueScoresInput {
@@ -30,6 +35,12 @@ export async function recomputeResourceValueScores(
skills: true,
lcrCents: true,
chargeabilityTarget: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
}),
db.systemSettings?.findUnique?.({ where: { id: "singleton" } }) ?? Promise.resolve(null),
@@ -39,11 +50,27 @@ export async function recomputeResourceValueScores(
return { updated: 0 };
}
const periodStart = new Date(Date.now() - daysBack * 24 * 60 * 60 * 1000);
const periodEnd = new Date();
const bookings = await listAssignmentBookings(db, {
startDate: new Date(Date.now() - daysBack * 24 * 60 * 60 * 1000),
endDate: new Date(),
startDate: periodStart,
endDate: periodEnd,
resourceIds: resources.map((resource) => resource.id),
});
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,
})),
periodStart,
periodEnd,
);
const defaultWeights = {
skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH,
@@ -65,20 +92,29 @@ export async function recomputeResourceValueScores(
yearsExperience?: number;
};
const totalWorkDays = daysBack * (5 / 7);
const availableHours = totalWorkDays * 8;
const updates = resources.map((resource) => {
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
const bookedHours = resourceBookings.reduce((sum, booking) => {
const days = Math.max(
0,
(new Date(booking.endDate).getTime() - new Date(booking.startDate).getTime()) /
(1000 * 60 * 60 * 24) +
1,
);
return sum + booking.hoursPerDay * days;
}, 0);
const availability = resource.availability as unknown as WeekdayAvailability;
const context = contexts.get(resource.id);
const availableHours = calculateEffectiveAvailableHours({
availability,
periodStart,
periodEnd,
context,
});
const bookedHours = resourceBookings.reduce(
(sum, booking) =>
sum + calculateEffectiveAllocationHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart,
periodEnd,
context,
}),
0,
);
const currentChargeability =
availableHours > 0 ? Math.min(100, (bookedHours / availableHours) * 100) : 0;
const skills = (resource.skills as unknown as SkillRow[]) ?? [];