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:
2026-03-26 11:45:50 +01:00
parent a9107add7b
commit 92a982b151
13 changed files with 721 additions and 42 deletions
@@ -0,0 +1,118 @@
import { describe, it, expect } from "vitest";
import { calculateShoringRatio, type ShoringAssignment } from "../allocation/shoring-ratio.js";
function make(resourceId: string, countryCode: string | null, hoursPerDay = 8, workingDays = 20): ShoringAssignment {
return { resourceId, countryCode, hoursPerDay, workingDays };
}
describe("calculateShoringRatio", () => {
it("returns zeros for empty assignments", () => {
const r = calculateShoringRatio([]);
expect(r.totalHours).toBe(0);
expect(r.offshoreRatio).toBe(0);
expect(r.isAboveThreshold).toBe(false);
});
it("100% German → 0% offshore", () => {
const r = calculateShoringRatio([make("r1", "DE"), make("r2", "DE")]);
expect(r.onshoreRatio).toBe(100);
expect(r.offshoreRatio).toBe(0);
expect(r.isAboveThreshold).toBe(false);
});
it("100% offshore → 100% offshore, above threshold", () => {
const r = calculateShoringRatio([make("r1", "ES"), make("r2", "IN")]);
expect(r.offshoreRatio).toBe(100);
expect(r.isAboveThreshold).toBe(true);
});
it("50/50 mix → 50% offshore, below 55% threshold", () => {
const r = calculateShoringRatio([make("r1", "DE"), make("r2", "ES")]);
expect(r.offshoreRatio).toBe(50);
expect(r.isAboveThreshold).toBe(false);
});
it("40% onshore / 60% offshore → above 55% threshold", () => {
const assignments = [
make("r1", "DE", 8, 20), // 160h
make("r2", "DE", 8, 20), // 160h → 320h onshore
make("r3", "ES", 8, 20), // 160h
make("r4", "IN", 8, 20), // 160h
make("r5", "IN", 8, 20), // 160h → 480h offshore
];
const r = calculateShoringRatio(assignments);
expect(r.offshoreRatio).toBe(60);
expect(r.isAboveThreshold).toBe(true);
});
it("custom threshold: 30% → 40% offshore is above", () => {
const assignments = [
make("r1", "DE", 8, 20), // 160h onshore
make("r2", "DE", 8, 20), // 160h onshore → 320h
make("r3", "ES", 4, 20), // 80h offshore
make("r4", "IN", 4, 20), // 80h offshore → 160h
];
// Total 480h, offshore 160h = 33%
const r = calculateShoringRatio(assignments, 30);
expect(r.offshoreRatio).toBe(33);
expect(r.isAboveThreshold).toBe(true);
});
it("resource without country counts as offshore", () => {
const r = calculateShoringRatio([make("r1", "DE"), make("r2", null)]);
expect(r.offshoreRatio).toBe(50);
expect(r.unknownCount).toBe(1);
expect(r.byCountry["UNKNOWN"]).toBeDefined();
});
it("country code is case-insensitive", () => {
const r = calculateShoringRatio([make("r1", "de"), make("r2", "De")]);
expect(r.onshoreRatio).toBe(100);
expect(r.offshoreRatio).toBe(0);
});
it("custom onshore country code", () => {
const r = calculateShoringRatio([make("r1", "ES"), make("r2", "DE")], 55, "ES");
expect(r.onshoreCountryCode).toBe("ES");
expect(r.onshoreHours).toBe(160); // r1 is onshore
expect(r.offshoreHours).toBe(160); // r2 is offshore (DE is not onshore here)
});
it("byCountry breakdown is correct", () => {
const r = calculateShoringRatio([
make("r1", "DE", 8, 10),
make("r2", "ES", 8, 10),
make("r3", "ES", 4, 10),
make("r4", "IN", 8, 10),
]);
expect(r.byCountry["DE"].resourceCount).toBe(1);
expect(r.byCountry["ES"].resourceCount).toBe(2);
expect(r.byCountry["IN"].resourceCount).toBe(1);
expect(r.byCountry["DE"].hours).toBe(80);
expect(r.byCountry["ES"].hours).toBe(120);
expect(r.byCountry["IN"].hours).toBe(80);
});
it("exactly at threshold is above", () => {
// 55% offshore = at threshold = isAboveThreshold true (>= not >)
// Need 55/100 = 11/20 ratio
const assignments = [
make("r1", "DE", 9, 20), // 180h onshore
make("r2", "ES", 11, 20), // 220h offshore
];
// Total 400h, offshore 220h = 55%
const r = calculateShoringRatio(assignments);
expect(r.offshoreRatio).toBe(55);
expect(r.isAboveThreshold).toBe(true);
});
it("weighted by hours — FTE matters", () => {
const r = calculateShoringRatio([
make("r1", "DE", 8, 20), // 160h full-time onshore
make("r2", "ES", 2, 20), // 40h part-time offshore
]);
// Total 200h, offshore 40h = 20%
expect(r.offshoreRatio).toBe(20);
expect(r.isAboveThreshold).toBe(false);
});
});
+1
View File
@@ -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,
};
}