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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user