fix(dashboard): stabilize budget forecast derivation typing

This commit is contained in:
2026-03-31 22:11:39 +02:00
parent 459ab6911b
commit 1a90f4b930
4 changed files with 220 additions and 8 deletions
+1
View File
@@ -83,6 +83,7 @@ export {
type DashboardChargeabilityDerivation,
type DashboardChargeabilityRow,
getDashboardBudgetForecast,
type BudgetForecastDerivationSummary,
type BudgetForecastRow,
type BudgetForecastLocationSummary,
type PeakTimesPeriodDerivation,
@@ -15,6 +15,20 @@ export interface BudgetForecastLocationSummary {
burnRateCents: number;
}
export interface BudgetForecastDerivationSummary {
periodStart: string;
periodEnd: string;
calendarContextCount: number;
holidayAwareAssignmentCount: number;
fallbackAssignmentCount: number;
baseBurnRateCents: number;
adjustedBurnRateCents: number;
publicHolidayDayEquivalent: number;
publicHolidayCostDeductionCents: number;
absenceDayEquivalent: number;
absenceCostDeductionCents: number;
}
export interface BudgetForecastRow {
projectId?: string;
projectName: string;
@@ -29,6 +43,29 @@ export interface BudgetForecastRow {
pctUsed: number;
activeAssignmentCount?: number;
calendarLocations?: BudgetForecastLocationSummary[];
derivation?: BudgetForecastDerivationSummary;
}
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
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 hasAvailability<T extends { availability?: unknown }>(
@@ -54,6 +91,86 @@ function buildLocationKey(input: {
});
}
function summarizeBurnDerivation(input: {
availability: WeekdayAvailability;
startDate: Date;
endDate: Date;
dailyCostCents: number;
periodStart: Date;
periodEnd: Date;
context: Awaited<ReturnType<typeof loadDailyAvailabilityContexts>> extends Map<string, infer TValue>
? TValue | undefined
: never;
}) {
const baseBurnRateCents = calculateEffectiveAllocationCostCents({
availability: input.availability,
startDate: input.startDate,
endDate: input.endDate,
dailyCostCents: input.dailyCostCents,
periodStart: input.periodStart,
periodEnd: input.periodEnd,
context: undefined,
});
const adjustedBurnRateCents = calculateEffectiveAllocationCostCents({
availability: input.availability,
startDate: input.startDate,
endDate: input.endDate,
dailyCostCents: input.dailyCostCents,
periodStart: input.periodStart,
periodEnd: input.periodEnd,
context: input.context,
});
const overlapStart = new Date(
Math.max(input.startDate.getTime(), input.periodStart.getTime()),
);
const overlapEnd = new Date(
Math.min(input.endDate.getTime(), input.periodEnd.getTime()),
);
let publicHolidayDayEquivalent = 0;
let publicHolidayCostDeductionCents = 0;
let absenceDayEquivalent = 0;
let absenceCostDeductionCents = 0;
if (overlapStart <= overlapEnd) {
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) {
const isoDate = toIsoDate(cursor);
if (input.context?.holidayDates.has(isoDate)) {
publicHolidayDayEquivalent += 1;
publicHolidayCostDeductionCents += input.dailyCostCents;
} else {
const vacationFraction = Math.min(
1,
Math.max(0, input.context?.vacationFractionsByDate.get(isoDate) ?? 0),
);
if (vacationFraction > 0) {
absenceDayEquivalent += vacationFraction;
absenceCostDeductionCents += input.dailyCostCents * vacationFraction;
}
}
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
return {
baseBurnRateCents,
adjustedBurnRateCents,
publicHolidayDayEquivalent,
publicHolidayCostDeductionCents: Math.round(publicHolidayCostDeductionCents),
absenceDayEquivalent,
absenceCostDeductionCents: Math.round(absenceCostDeductionCents),
};
}
export async function getDashboardBudgetForecast(
db: PrismaClient,
): Promise<BudgetForecastRow[]> {
@@ -148,6 +265,7 @@ export async function getDashboardBudgetForecast(
const monthlyBurnByProject = new Map<string, number>();
const activeAssignmentCountByProject = new Map<string, number>();
const activeLocationsByProject = new Map<string, Map<string, BudgetForecastLocationSummary>>();
const derivationByProject = new Map<string, Omit<BudgetForecastDerivationSummary, "periodStart" | "periodEnd" | "calendarContextCount">>();
for (const assignment of assignments) {
const totalCost = hasAvailability(assignment.resource)
@@ -169,8 +287,8 @@ export async function getDashboardBudgetForecast(
);
if (assignment.startDate <= now && assignment.endDate >= now) {
const monthlyContribution = hasAvailability(assignment.resource)
? calculateEffectiveAllocationCostCents({
const derivation = hasAvailability(assignment.resource)
? summarizeBurnDerivation({
availability: assignment.resource.availability as unknown as WeekdayAvailability,
startDate: assignment.startDate,
endDate: assignment.endDate,
@@ -179,7 +297,9 @@ export async function getDashboardBudgetForecast(
periodEnd: monthEnd,
context: contexts.get(assignment.resource.id),
})
: (assignment.dailyCostCents ?? 0) * 22;
: null;
const monthlyContribution = derivation?.adjustedBurnRateCents ?? (assignment.dailyCostCents ?? 0) * 22;
const baseMonthlyContribution = derivation?.baseBurnRateCents ?? monthlyContribution;
monthlyBurnByProject.set(
assignment.projectId,
(monthlyBurnByProject.get(assignment.projectId) ?? 0) + monthlyContribution,
@@ -188,6 +308,28 @@ export async function getDashboardBudgetForecast(
assignment.projectId,
(activeAssignmentCountByProject.get(assignment.projectId) ?? 0) + 1,
);
const existingDerivation = derivationByProject.get(assignment.projectId) ?? {
holidayAwareAssignmentCount: 0,
fallbackAssignmentCount: 0,
baseBurnRateCents: 0,
adjustedBurnRateCents: 0,
publicHolidayDayEquivalent: 0,
publicHolidayCostDeductionCents: 0,
absenceDayEquivalent: 0,
absenceCostDeductionCents: 0,
};
existingDerivation.baseBurnRateCents += baseMonthlyContribution;
existingDerivation.adjustedBurnRateCents += monthlyContribution;
if (derivation) {
existingDerivation.holidayAwareAssignmentCount += 1;
existingDerivation.publicHolidayDayEquivalent += derivation.publicHolidayDayEquivalent;
existingDerivation.publicHolidayCostDeductionCents += derivation.publicHolidayCostDeductionCents;
existingDerivation.absenceDayEquivalent += derivation.absenceDayEquivalent;
existingDerivation.absenceCostDeductionCents += derivation.absenceCostDeductionCents;
} else {
existingDerivation.fallbackAssignmentCount += 1;
}
derivationByProject.set(assignment.projectId, existingDerivation);
const locationSummaries = activeLocationsByProject.get(assignment.projectId) ?? new Map();
const locationKey = buildLocationKey({
@@ -228,6 +370,8 @@ export async function getDashboardBudgetForecast(
estimatedExhaustionDate = exhaustionDate.toISOString().slice(0, 10);
}
const derivation = derivationByProject.get(project.id);
return {
projectId: project.id,
projectName: project.name,
@@ -243,6 +387,16 @@ export async function getDashboardBudgetForecast(
activeAssignmentCount: activeAssignmentCountByProject.get(project.id) ?? 0,
calendarLocations: Array.from(activeLocationsByProject.get(project.id)?.values() ?? [])
.sort((left, right) => right.burnRateCents - left.burnRateCents),
...(derivation
? {
derivation: {
periodStart: toIsoDate(monthStart),
periodEnd: toIsoDate(monthEnd),
calendarContextCount: activeLocationsByProject.get(project.id)?.size ?? 0,
...derivation,
},
}
: {}),
};
});
@@ -12,6 +12,7 @@ export {
export {
getDashboardTopValueResources,
type GetDashboardTopValueResourcesInput,
type DashboardTopValueResourceRow,
} from "./get-top-value-resources.js";
export {
@@ -31,6 +32,7 @@ export {
export {
getDashboardBudgetForecast,
type BudgetForecastDerivationSummary,
type BudgetForecastRow,
type BudgetForecastLocationSummary,
} from "./get-budget-forecast.js";