From 1a90f4b93044c25f58e11a72f9092f883d692ef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 22:11:39 +0200 Subject: [PATCH] fix(dashboard): stabilize budget forecast derivation typing --- .../src/router/dashboard-procedure-support.ts | 65 ++++++- packages/application/src/index.ts | 1 + .../dashboard/get-budget-forecast.ts | 160 +++++++++++++++++- .../src/use-cases/dashboard/index.ts | 2 + 4 files changed, 220 insertions(+), 8 deletions(-) diff --git a/packages/api/src/router/dashboard-procedure-support.ts b/packages/api/src/router/dashboard-procedure-support.ts index 54d2da5..2cceff8 100644 --- a/packages/api/src/router/dashboard-procedure-support.ts +++ b/packages/api/src/router/dashboard-procedure-support.ts @@ -1,4 +1,5 @@ import { + type BudgetForecastRow, getDashboardBudgetForecast, getDashboardChargeabilityOverview, getDashboardDemand, @@ -66,6 +67,55 @@ type DashboardDemandInput = z.infer; type DashboardDetailInput = z.infer; type DashboardChargeabilityOverviewInput = z.infer; +type DashboardBudgetForecastCalendarLocation = { + countryCode: string | null; + countryName: string | null; + federalState: string | null; + metroCityName: string | null; + activeAssignmentCount: number; + burnRateCents: number; +}; + +type DashboardBudgetForecastDerivation = { + periodStart: string; + periodEnd: string; + calendarContextCount: number; + holidayAwareAssignmentCount: number; + fallbackAssignmentCount: number; + baseBurnRateCents: number; + adjustedBurnRateCents: number; + publicHolidayDayEquivalent: number; + publicHolidayCostDeductionCents: number; + absenceDayEquivalent: number; + absenceCostDeductionCents: number; +}; + +type DashboardBudgetForecastDetail = { + forecasts: Array<{ + projectId: string | null; + projectName: string; + shortCode: string; + clientId: string | null; + clientName: string | null; + budget: string; + budgetCents: number; + spent: string; + spentCents: number; + remaining: string; + remainingCents: number; + projected: string; + projectedCents: number; + burnRate: string; + burnRateCents: number; + utilization: string; + estimatedExhaustionDate: string | null; + activeAssignmentCount: number | null; + calendarLocations: DashboardBudgetForecastCalendarLocation[]; + derivation: DashboardBudgetForecastDerivation | null; + burnStatus: "ahead" | "on_track" | "not_started"; + }>; +}; + function round1(value: number): number { return Math.round(value * 10) / 10; } @@ -98,7 +148,7 @@ function mapProjectHealthDetailRows(rows: Awaited>) { +function mapBudgetForecastDetailRows(rows: BudgetForecastRow[]): DashboardBudgetForecastDetail { return { forecasts: rows.map((forecast) => ({ projectId: forecast.projectId ?? null, @@ -124,6 +174,7 @@ function mapBudgetForecastDetailRows(rows: Awaited= 100 ? "ahead" : forecast.burnRate > 0 @@ -374,9 +425,11 @@ export async function getDashboardChargeabilityOverviewRead( return result; } -export async function getDashboardBudgetForecastRead(ctx: DashboardProcedureContext) { +export async function getDashboardBudgetForecastRead( + ctx: DashboardProcedureContext, +): Promise { const cacheKey = "budgetForecast"; - const cached = await cacheGet>>(cacheKey); + const cached = await cacheGet(cacheKey); if (cached) return cached; const result = await getDashboardBudgetForecast(ctx.db); @@ -384,8 +437,10 @@ export async function getDashboardBudgetForecastRead(ctx: DashboardProcedureCont return result; } -export async function getDashboardBudgetForecastDetail(ctx: DashboardProcedureContext) { - const budgetForecast = await getDashboardBudgetForecast(ctx.db); +export async function getDashboardBudgetForecastDetail( + ctx: DashboardProcedureContext, +): Promise { + const budgetForecast: BudgetForecastRow[] = await getDashboardBudgetForecast(ctx.db); return mapBudgetForecastDetailRows(budgetForecast); } diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 597416a..6c9781a 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -83,6 +83,7 @@ export { type DashboardChargeabilityDerivation, type DashboardChargeabilityRow, getDashboardBudgetForecast, + type BudgetForecastDerivationSummary, type BudgetForecastRow, type BudgetForecastLocationSummary, type PeakTimesPeriodDerivation, diff --git a/packages/application/src/use-cases/dashboard/get-budget-forecast.ts b/packages/application/src/use-cases/dashboard/get-budget-forecast.ts index 0a83ff2..3afa871 100644 --- a/packages/application/src/use-cases/dashboard/get-budget-forecast.ts +++ b/packages/application/src/use-cases/dashboard/get-budget-forecast.ts @@ -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( @@ -54,6 +91,86 @@ function buildLocationKey(input: { }); } +function summarizeBurnDerivation(input: { + availability: WeekdayAvailability; + startDate: Date; + endDate: Date; + dailyCostCents: number; + periodStart: Date; + periodEnd: Date; + context: Awaited> extends Map + ? 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 { @@ -148,6 +265,7 @@ export async function getDashboardBudgetForecast( const monthlyBurnByProject = new Map(); const activeAssignmentCountByProject = new Map(); const activeLocationsByProject = new Map>(); + const derivationByProject = new Map>(); 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, + }, + } + : {}), }; }); diff --git a/packages/application/src/use-cases/dashboard/index.ts b/packages/application/src/use-cases/dashboard/index.ts index d7a87a5..b1566df 100644 --- a/packages/application/src/use-cases/dashboard/index.ts +++ b/packages/application/src/use-cases/dashboard/index.ts @@ -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";