feat: Nearshore-Ratio indicator per project
Engine (packages/engine): - calculateShoringRatio() pure function: onshore/offshore hours, country breakdown, threshold check, weighted by hours not headcount - 12 unit tests: empty, 100% onshore/offshore, mixed ratios, custom threshold, case-insensitive, unknown country, FTE weighting Schema: - Project.shoringThreshold (default 55%) — per-project configurable - Project.onshoreCountryCode (default "DE") — configurable onshore country API (project router): - getShoringRatio query: loads assignments with resource.country, computes ratio, returns full breakdown - update mutation: accepts shoringThreshold + onshoreCountryCode UI: - ShoringIndicator: stacked horizontal bar with country segments, severity badge (green/yellow/red), hover tooltip, dark theme - ShoringBadge: mini colored dot + % for project list column - ProjectModal: "Max Offshore %" number input - Project detail: indicator after budget status card - Project list: "Shoring" column (default hidden, toggleable) AI Assistant: - get_shoring_ratio tool: human-readable breakdown with threshold alert Colors: green (<threshold-10), yellow (threshold-10 to threshold), red (>=threshold) Default: 55% offshore threshold, "DE" as onshore country Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -3,3 +3,4 @@ export * from "./availability-validator.js";
|
||||
export * from "./recurrence.js";
|
||||
export * from "./chargeability.js";
|
||||
export * from "./duplicate-check.js";
|
||||
export * from "./shoring-ratio.js";
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Calculate the onshore/offshore staffing ratio for a project.
|
||||
* "Onshore" = resources in the configured country (default: DE).
|
||||
* "Offshore" = everything else (including resources without a country).
|
||||
*/
|
||||
|
||||
export interface ShoringAssignment {
|
||||
resourceId: string;
|
||||
countryCode: string | null;
|
||||
hoursPerDay: number;
|
||||
workingDays: number;
|
||||
}
|
||||
|
||||
export interface ShoringCountryBreakdown {
|
||||
hours: number;
|
||||
pct: number;
|
||||
resourceCount: number;
|
||||
}
|
||||
|
||||
export interface ShoringResult {
|
||||
totalHours: number;
|
||||
onshoreHours: number;
|
||||
offshoreHours: number;
|
||||
onshoreRatio: number; // 0-100
|
||||
offshoreRatio: number; // 0-100
|
||||
threshold: number;
|
||||
isAboveThreshold: boolean;
|
||||
onshoreCountryCode: string;
|
||||
/** Breakdown per country code ("DE" → { hours, pct, resourceCount }) */
|
||||
byCountry: Record<string, ShoringCountryBreakdown>;
|
||||
/** Resources with no country assigned */
|
||||
unknownCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param assignments - All active assignments for a project with their resource's country
|
||||
* @param threshold - Max offshore percentage before alert (default 55)
|
||||
* @param onshoreCountryCode - Country code considered "onshore" (default "DE")
|
||||
*/
|
||||
export function calculateShoringRatio(
|
||||
assignments: ShoringAssignment[],
|
||||
threshold = 55,
|
||||
onshoreCountryCode = "DE",
|
||||
): ShoringResult {
|
||||
if (assignments.length === 0) {
|
||||
return {
|
||||
totalHours: 0,
|
||||
onshoreHours: 0,
|
||||
offshoreHours: 0,
|
||||
onshoreRatio: 0,
|
||||
offshoreRatio: 0,
|
||||
threshold,
|
||||
isAboveThreshold: false,
|
||||
onshoreCountryCode,
|
||||
byCountry: {},
|
||||
unknownCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const byCountry: Record<string, { hours: number; resources: Set<string> }> = {};
|
||||
let totalHours = 0;
|
||||
let onshoreHours = 0;
|
||||
let unknownCount = 0;
|
||||
const unknownResources = new Set<string>();
|
||||
|
||||
for (const a of assignments) {
|
||||
const hours = a.hoursPerDay * a.workingDays;
|
||||
totalHours += hours;
|
||||
|
||||
const code = a.countryCode?.toUpperCase() ?? "UNKNOWN";
|
||||
|
||||
if (code === "UNKNOWN") {
|
||||
if (!unknownResources.has(a.resourceId)) {
|
||||
unknownCount++;
|
||||
unknownResources.add(a.resourceId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!byCountry[code]) {
|
||||
byCountry[code] = { hours: 0, resources: new Set() };
|
||||
}
|
||||
byCountry[code].hours += hours;
|
||||
byCountry[code].resources.add(a.resourceId);
|
||||
|
||||
if (code === onshoreCountryCode.toUpperCase()) {
|
||||
onshoreHours += hours;
|
||||
}
|
||||
}
|
||||
|
||||
const offshoreHours = totalHours - onshoreHours;
|
||||
const offshoreRatio = totalHours > 0 ? Math.round((offshoreHours / totalHours) * 100) : 0;
|
||||
const onshoreRatio = totalHours > 0 ? 100 - offshoreRatio : 0;
|
||||
|
||||
const result: Record<string, ShoringCountryBreakdown> = {};
|
||||
for (const [code, data] of Object.entries(byCountry)) {
|
||||
result[code] = {
|
||||
hours: data.hours,
|
||||
pct: totalHours > 0 ? Math.round((data.hours / totalHours) * 100) : 0,
|
||||
resourceCount: data.resources.size,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
totalHours,
|
||||
onshoreHours,
|
||||
offshoreHours,
|
||||
onshoreRatio,
|
||||
offshoreRatio,
|
||||
threshold,
|
||||
isAboveThreshold: offshoreRatio >= threshold,
|
||||
onshoreCountryCode: onshoreCountryCode.toUpperCase(),
|
||||
byCountry: result,
|
||||
unknownCount,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user