fix(dashboard): stabilize budget forecast derivation typing
This commit is contained in:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user