refactor(api): split dashboard detail shaping

This commit is contained in:
2026-03-31 22:26:52 +02:00
parent a9028290f2
commit 46d00c2635
3 changed files with 373 additions and 113 deletions
@@ -290,6 +290,40 @@ describe("dashboard router", () => {
staffingHealth: 40,
timelineHealth: 30,
compositeScore: 35,
budgetCents: 100_000,
spentCents: 82_000,
remainingBudgetCents: 18_000,
budgetUtilizationPercent: 82,
demandHeadcountTotal: 5,
demandHeadcountFilled: 2,
demandHeadcountOpen: 3,
demandRequirementCount: 2,
plannedEndDate: new Date("2026-06-30T00:00:00.000Z"),
daysUntilEndDate: 12,
timelineStatus: "DUE_SOON",
calendarLocations: [
{
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Munich",
assignmentCount: 2,
spentCents: 52_000,
},
],
derivation: {
periodStart: "2026-06-01",
periodEnd: "2026-06-30",
calendarContextCount: 1,
holidayAwareAssignmentCount: 2,
fallbackAssignmentCount: 0,
baseSpentCents: 90_000,
adjustedSpentCents: 82_000,
publicHolidayDayEquivalent: 1,
publicHolidayCostDeductionCents: 5_000,
absenceDayEquivalent: 0.6,
absenceCostDeductionCents: 3_000,
},
},
{
id: "project_healthy",
@@ -302,6 +336,18 @@ describe("dashboard router", () => {
staffingHealth: 92,
timelineHealth: 86,
compositeScore: 89,
budgetCents: 200_000,
spentCents: 20_000,
remainingBudgetCents: 180_000,
budgetUtilizationPercent: 10,
demandHeadcountTotal: 4,
demandHeadcountFilled: 4,
demandHeadcountOpen: 0,
demandRequirementCount: 1,
plannedEndDate: new Date("2026-09-15T00:00:00.000Z"),
daysUntilEndDate: 89,
timelineStatus: "ON_TRACK",
calendarLocations: [],
},
]);
@@ -321,6 +367,50 @@ describe("dashboard router", () => {
staffing: 40,
timeline: 30,
rating: "critical",
budgetBasis: {
budgetCents: 100_000,
spentCents: 82_000,
remainingBudgetCents: 18_000,
budgetUtilizationPercent: 82,
calendarLocations: [
{
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Munich",
assignmentCount: 2,
spentCents: 52_000,
},
],
derivation: {
periodStart: "2026-06-01",
periodEnd: "2026-06-30",
calendarContextCount: 1,
holidayAwareAssignmentCount: 2,
fallbackAssignmentCount: 0,
baseSpentCents: 90_000,
adjustedSpentCents: 82_000,
publicHolidayDayEquivalent: 1,
publicHolidayCostDeductionCents: 5_000,
absenceDayEquivalent: 0.6,
absenceCostDeductionCents: 3_000,
},
},
staffingBasis: {
demandHeadcountTotal: 5,
demandHeadcountFilled: 2,
demandHeadcountOpen: 3,
demandRequirementCount: 2,
},
timelineBasis: {
plannedEndDate: "2026-06-30T00:00:00.000Z",
daysUntilEndDate: 12,
timelineStatus: "DUE_SOON",
},
context: {
clientId: "client_1",
clientName: "Acme",
},
},
{
projectId: "project_healthy",
@@ -332,6 +422,29 @@ describe("dashboard router", () => {
staffing: 92,
timeline: 86,
rating: "healthy",
budgetBasis: {
budgetCents: 200_000,
spentCents: 20_000,
remainingBudgetCents: 180_000,
budgetUtilizationPercent: 10,
calendarLocations: [],
derivation: null,
},
staffingBasis: {
demandHeadcountTotal: 4,
demandHeadcountFilled: 4,
demandHeadcountOpen: 0,
demandRequirementCount: 1,
},
timelineBasis: {
plannedEndDate: "2026-09-15T00:00:00.000Z",
daysUntilEndDate: 89,
timelineStatus: "ON_TRACK",
},
context: {
clientId: "client_1",
clientName: "Acme",
},
},
],
summary: {
@@ -476,6 +589,19 @@ describe("dashboard router", () => {
estimatedExhaustionDate: "2026-06-30",
pctUsed: 40,
activeAssignmentCount: 2,
derivation: {
periodStart: "2026-03-01",
periodEnd: "2026-03-31",
calendarContextCount: 1,
holidayAwareAssignmentCount: 2,
fallbackAssignmentCount: 0,
baseBurnRateCents: 12_000,
adjustedBurnRateCents: 10_000,
publicHolidayDayEquivalent: 1,
publicHolidayCostDeductionCents: 1_000,
absenceDayEquivalent: 0.5,
absenceCostDeductionCents: 1_000,
},
calendarLocations: [
{
countryCode: "DE",
@@ -496,6 +622,11 @@ describe("dashboard router", () => {
expect(result[0]).toMatchObject({
projectName: "Alpha",
activeAssignmentCount: 2,
derivation: {
holidayAwareAssignmentCount: 2,
baseBurnRateCents: 12_000,
adjustedBurnRateCents: 10_000,
},
calendarLocations: [
expect.objectContaining({
countryCode: "DE",
@@ -522,6 +653,19 @@ describe("dashboard router", () => {
estimatedExhaustionDate: "2026-06-30",
pctUsed: 40,
activeAssignmentCount: 2,
derivation: {
periodStart: "2026-03-01",
periodEnd: "2026-03-31",
calendarContextCount: 1,
holidayAwareAssignmentCount: 2,
fallbackAssignmentCount: 0,
baseBurnRateCents: 12_000,
adjustedBurnRateCents: 10_000,
publicHolidayDayEquivalent: 1,
publicHolidayCostDeductionCents: 1_000,
absenceDayEquivalent: 0.5,
absenceCostDeductionCents: 1_000,
},
calendarLocations: [
{
countryCode: "DE",
@@ -551,6 +695,13 @@ describe("dashboard router", () => {
projectedCents: 100_000,
burnRateCents: 10_000,
utilization: "40%",
derivation: expect.objectContaining({
holidayAwareAssignmentCount: 2,
baseBurnRateCents: 12_000,
adjustedBurnRateCents: 10_000,
publicHolidayCostDeductionCents: 1_000,
absenceCostDeductionCents: 1_000,
}),
burnStatus: "on_track",
calendarLocations: [
expect.objectContaining({
@@ -597,6 +748,7 @@ describe("dashboard router", () => {
valueScore: 91,
lcrCents: 9_500,
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Augsburg",
},
@@ -644,6 +796,7 @@ describe("dashboard router", () => {
lcr: "95,00 EUR",
valueScore: 91,
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Augsburg",
},
@@ -0,0 +1,213 @@
import { type BudgetForecastRow, getDashboardProjectHealth } from "@capakraken/application";
import { fmtEur } from "../lib/format-utils.js";
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;
};
export 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";
}>;
};
type DashboardProjectHealthCalendarLocation = {
countryCode: string | null;
countryName: string | null;
federalState: string | null;
metroCityName: string | null;
assignmentCount: number;
spentCents: number;
};
type DashboardProjectHealthDerivation = {
periodStart: string;
periodEnd: string;
calendarContextCount: number;
holidayAwareAssignmentCount: number;
fallbackAssignmentCount: number;
baseSpentCents: number;
adjustedSpentCents: number;
publicHolidayDayEquivalent: number;
publicHolidayCostDeductionCents: number;
absenceDayEquivalent: number;
absenceCostDeductionCents: number;
};
export type DashboardProjectHealthDetail = {
projects: Array<{
projectId: string;
projectName: string;
shortCode: string;
status: string;
overall: number;
budget: number;
staffing: number;
timeline: number;
rating: "healthy" | "at_risk" | "critical";
budgetBasis: {
budgetCents: number | null;
spentCents: number;
remainingBudgetCents: number | null;
budgetUtilizationPercent: number | null;
calendarLocations: DashboardProjectHealthCalendarLocation[];
derivation: DashboardProjectHealthDerivation | null;
};
staffingBasis: {
demandHeadcountTotal: number;
demandHeadcountFilled: number;
demandHeadcountOpen: number;
demandRequirementCount: number;
};
timelineBasis: {
plannedEndDate: string | null;
daysUntilEndDate: number | null;
timelineStatus: "ON_TRACK" | "DUE_SOON" | "OVERDUE" | "UNSCHEDULED";
};
context: {
clientId: string | null;
clientName: string | null;
};
}>;
summary: {
healthy: number;
atRisk: number;
critical: number;
};
};
export function mapProjectHealthDetailRows(
rows: Awaited<ReturnType<typeof getDashboardProjectHealth>>,
): DashboardProjectHealthDetail {
const projects: DashboardProjectHealthDetail["projects"] = rows
.map((project): DashboardProjectHealthDetail["projects"][number] => {
const overall = project.compositeScore;
const rating: DashboardProjectHealthDetail["projects"][number]["rating"] = overall >= 80
? "healthy"
: overall >= 50
? "at_risk"
: "critical";
return {
projectId: project.id,
projectName: project.projectName,
shortCode: project.shortCode,
status: project.status,
overall,
budget: project.budgetHealth,
staffing: project.staffingHealth,
timeline: project.timelineHealth,
rating,
budgetBasis: {
budgetCents: project.budgetCents ?? null,
spentCents: project.spentCents ?? 0,
remainingBudgetCents: project.remainingBudgetCents ?? null,
budgetUtilizationPercent: project.budgetUtilizationPercent ?? null,
calendarLocations: project.calendarLocations ?? [],
derivation: project.derivation ?? null,
},
staffingBasis: {
demandHeadcountTotal: project.demandHeadcountTotal ?? 0,
demandHeadcountFilled: project.demandHeadcountFilled ?? 0,
demandHeadcountOpen: project.demandHeadcountOpen ?? 0,
demandRequirementCount: project.demandRequirementCount ?? 0,
},
timelineBasis: {
plannedEndDate: project.plannedEndDate?.toISOString() ?? null,
daysUntilEndDate: project.daysUntilEndDate ?? null,
timelineStatus: project.timelineStatus ?? "UNSCHEDULED",
},
context: {
clientId: project.clientId ?? null,
clientName: project.clientName ?? null,
},
};
})
.sort((left, right) => left.overall - right.overall);
return {
projects,
summary: {
healthy: projects.filter((project) => project.rating === "healthy").length,
atRisk: projects.filter((project) => project.rating === "at_risk").length,
critical: projects.filter((project) => project.rating === "critical").length,
},
};
}
export function mapBudgetForecastDetailRows(
rows: BudgetForecastRow[],
): DashboardBudgetForecastDetail {
return {
forecasts: rows.map((forecast) => ({
projectId: forecast.projectId ?? null,
projectName: forecast.projectName,
shortCode: forecast.shortCode,
clientId: forecast.clientId,
clientName: forecast.clientName,
budget: fmtEur(forecast.budgetCents),
budgetCents: forecast.budgetCents,
spent: fmtEur(forecast.spentCents),
spentCents: forecast.spentCents,
remaining: fmtEur(forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents)),
remainingCents: forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents),
projected: forecast.burnRate > 0
? fmtEur(forecast.spentCents + Math.max(0, forecast.budgetCents - forecast.spentCents))
: fmtEur(forecast.spentCents),
projectedCents: forecast.burnRate > 0
? Math.max(forecast.spentCents, forecast.budgetCents)
: forecast.spentCents,
burnRate: fmtEur(forecast.burnRate),
burnRateCents: forecast.burnRate,
utilization: `${forecast.pctUsed}%`,
estimatedExhaustionDate: forecast.estimatedExhaustionDate,
activeAssignmentCount: forecast.activeAssignmentCount ?? null,
calendarLocations: forecast.calendarLocations ?? [],
derivation: forecast.derivation ?? null,
burnStatus: forecast.pctUsed >= 100
? "ahead"
: forecast.burnRate > 0
? "on_track"
: "not_started",
})),
};
}
@@ -15,6 +15,11 @@ import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymizat
import { cacheGet, cacheSet } from "../lib/cache.js";
import { fmtEur } from "../lib/format-utils.js";
import type { TRPCContext } from "../trpc.js";
import {
type DashboardBudgetForecastDetail,
mapBudgetForecastDetailRows,
mapProjectHealthDetailRows,
} from "./dashboard-detail-support.js";
const DEFAULT_TTL = 60;
@@ -28,6 +33,7 @@ type TopValueResourceRow = {
valueScore: number | null;
lcrCents: number;
countryCode: string | null;
countryName: string | null;
federalState: string | null;
metroCityName: string | null;
};
@@ -67,123 +73,10 @@ type DashboardDemandInput = z.infer<typeof dashboardDemandInputSchema>;
type DashboardDetailInput = z.infer<typeof dashboardDetailInputSchema>;
type DashboardChargeabilityOverviewInput = z.infer<typeof dashboardChargeabilityOverviewInputSchema>;
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;
}
function mapProjectHealthDetailRows(rows: Awaited<ReturnType<typeof getDashboardProjectHealth>>) {
const projects = rows
.map((project) => {
const overall = project.compositeScore;
return {
projectId: project.id,
projectName: project.projectName,
shortCode: project.shortCode,
status: project.status,
overall,
budget: project.budgetHealth,
staffing: project.staffingHealth,
timeline: project.timelineHealth,
rating: overall >= 80 ? "healthy" : overall >= 50 ? "at_risk" : "critical",
};
})
.sort((left, right) => left.overall - right.overall);
return {
projects,
summary: {
healthy: projects.filter((project) => project.rating === "healthy").length,
atRisk: projects.filter((project) => project.rating === "at_risk").length,
critical: projects.filter((project) => project.rating === "critical").length,
},
};
}
function mapBudgetForecastDetailRows(rows: BudgetForecastRow[]): DashboardBudgetForecastDetail {
return {
forecasts: rows.map((forecast) => ({
projectId: forecast.projectId ?? null,
projectName: forecast.projectName,
shortCode: forecast.shortCode,
clientId: forecast.clientId,
clientName: forecast.clientName,
budget: fmtEur(forecast.budgetCents),
budgetCents: forecast.budgetCents,
spent: fmtEur(forecast.spentCents),
spentCents: forecast.spentCents,
remaining: fmtEur(forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents)),
remainingCents: forecast.remainingCents ?? (forecast.budgetCents - forecast.spentCents),
projected: forecast.burnRate > 0
? fmtEur(forecast.spentCents + Math.max(0, forecast.budgetCents - forecast.spentCents))
: fmtEur(forecast.spentCents),
projectedCents: forecast.burnRate > 0
? Math.max(forecast.spentCents, forecast.budgetCents)
: forecast.spentCents,
burnRate: fmtEur(forecast.burnRate),
burnRateCents: forecast.burnRate,
utilization: `${forecast.pctUsed}%`,
estimatedExhaustionDate: forecast.estimatedExhaustionDate,
activeAssignmentCount: forecast.activeAssignmentCount ?? null,
calendarLocations: forecast.calendarLocations ?? [],
derivation: forecast.derivation ?? null,
burnStatus: forecast.pctUsed >= 100
? "ahead"
: forecast.burnRate > 0
? "on_track"
: "not_started",
})),
};
}
function mapStatisticsDetail(overview: Awaited<ReturnType<typeof getDashboardOverview>>) {
return {
activeResources: overview.activeResources,
@@ -357,6 +250,7 @@ export async function getDashboardDetail(ctx: DashboardProcedureContext, input:
lcr: fmtEur(resource.lcrCents),
valueScore: resource.valueScore ?? null,
countryCode: resource.countryCode ?? null,
countryName: resource.countryName ?? null,
federalState: resource.federalState ?? null,
metroCityName: resource.metroCityName ?? null,
}));