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:
@@ -1385,6 +1385,20 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_shoring_ratio",
|
||||
description: "Get the onshore/offshore staffing ratio for a project. Shows the percentage of work hours allocated to each country, whether the project exceeds its nearshore threshold, and a full country breakdown.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectId: { type: "string", description: "Project ID or short code" },
|
||||
},
|
||||
required: ["projectId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -5520,6 +5534,52 @@ const executors = {
|
||||
|
||||
return `Change history for ${params.entityType} "${entityName}" (${entries.length} entries):\n\n${lines.join("\n")}`;
|
||||
},
|
||||
|
||||
async get_shoring_ratio(params: { projectId: string }, ctx: ToolContext) {
|
||||
const sel = { id: true, name: true, shortCode: true, shoringThreshold: true, onshoreCountryCode: true } as const;
|
||||
let project = await ctx.db.project.findUnique({ where: { id: params.projectId }, select: sel });
|
||||
if (!project) {
|
||||
project = await ctx.db.project.findUnique({ where: { shortCode: params.projectId }, select: sel });
|
||||
}
|
||||
if (!project) return { error: `Project not found: ${params.projectId}` };
|
||||
|
||||
const assignments = await ctx.db.assignment.findMany({
|
||||
where: { projectId: project.id, status: { not: "CANCELLED" } },
|
||||
include: { resource: { include: { country: { select: { code: true } } } } },
|
||||
});
|
||||
|
||||
if (assignments.length === 0) {
|
||||
return `Project "${project.name}" (${project.shortCode}): No active assignments — shoring ratio not available.`;
|
||||
}
|
||||
|
||||
const { calculateShoringRatio: calcShoring } = await import("@planarchy/engine/allocation");
|
||||
|
||||
const mapped = assignments.map((a) => {
|
||||
const start = new Date(a.startDate);
|
||||
const end = new Date(a.endDate);
|
||||
const diffDays = Math.max(1, Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1);
|
||||
const workingDays = Math.max(1, Math.round(diffDays / 7 * 5));
|
||||
return {
|
||||
resourceId: a.resourceId,
|
||||
countryCode: a.resource.country?.code ?? null,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
workingDays,
|
||||
};
|
||||
});
|
||||
|
||||
const threshold = project.shoringThreshold ?? 55;
|
||||
const onshoreCode = project.onshoreCountryCode ?? "DE";
|
||||
const result = calcShoring(mapped, threshold, onshoreCode);
|
||||
|
||||
const countryParts = Object.entries(result.byCountry)
|
||||
.sort((a, b) => b[1].pct - a[1].pct)
|
||||
.map(([code, info]) => `${code} ${info.pct}% (${info.resourceCount} people)`)
|
||||
.join(", ");
|
||||
|
||||
const warning = result.isAboveThreshold ? ` -- Above ${threshold}% offshore threshold!` : "";
|
||||
|
||||
return `Project "${project.name}" (${project.shortCode}): ${result.onshoreRatio}% onshore (${onshoreCode}), ${result.offshoreRatio}% offshore. Breakdown: ${countryParts}.${warning}${result.unknownCount > 0 ? ` (${result.unknownCount} resource(s) without country)` : ""}`;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Executor ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { calculateShoringRatio, type ShoringAssignment } from "@planarchy/engine/allocation";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { paginate, paginateCursor, PaginationInputSchema, CursorInputSchema } from "../db/pagination.js";
|
||||
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
||||
@@ -106,6 +107,49 @@ export const projectRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
getShoringRatio: protectedProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const project = await ctx.db.project.findUnique({
|
||||
where: { id: input.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
shoringThreshold: true,
|
||||
onshoreCountryCode: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
||||
}
|
||||
|
||||
const assignments = await ctx.db.assignment.findMany({
|
||||
where: { projectId: input.projectId, status: { not: "CANCELLED" } },
|
||||
include: { resource: { include: { country: { select: { code: true } } } } },
|
||||
});
|
||||
|
||||
const mapped: ShoringAssignment[] = assignments.map((a) => {
|
||||
const start = new Date(a.startDate);
|
||||
const end = new Date(a.endDate);
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
const diffDays = Math.max(1, Math.round(diffMs / (1000 * 60 * 60 * 24)) + 1);
|
||||
const workingDays = Math.round(diffDays / 7 * 5);
|
||||
return {
|
||||
resourceId: a.resourceId,
|
||||
countryCode: a.resource.country?.code ?? null,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
workingDays: Math.max(1, workingDays),
|
||||
};
|
||||
});
|
||||
|
||||
return calculateShoringRatio(
|
||||
mapped,
|
||||
project.shoringThreshold ?? 55,
|
||||
project.onshoreCountryCode ?? "DE",
|
||||
);
|
||||
}),
|
||||
|
||||
create: managerProcedure
|
||||
.input(CreateProjectSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -205,6 +249,8 @@ export const projectRouter = createTRPCRouter({
|
||||
...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}),
|
||||
...(input.data.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.data.utilizationCategoryId || null } : {}),
|
||||
...(input.data.clientId !== undefined ? { clientId: input.data.clientId || null } : {}),
|
||||
...(input.data.shoringThreshold !== undefined ? { shoringThreshold: input.data.shoringThreshold } : {}),
|
||||
...(input.data.onshoreCountryCode !== undefined ? { onshoreCountryCode: input.data.onshoreCountryCode } : {}),
|
||||
} as unknown as Parameters<typeof ctx.db.project.update>[0]["data"],
|
||||
});
|
||||
|
||||
|
||||
@@ -812,7 +812,9 @@ model Project {
|
||||
startDate DateTime @db.Date
|
||||
endDate DateTime @db.Date
|
||||
status ProjectStatus @default(DRAFT)
|
||||
responsiblePerson String?
|
||||
responsiblePerson String?
|
||||
shoringThreshold Int? @default(55) // Max offshore % before alert (0-100)
|
||||
onshoreCountryCode String? @default("DE") // Country code considered "onshore"
|
||||
color String? // Hex color for timeline display, e.g. "#3b82f6"
|
||||
coverImageUrl String? @db.Text // Base64 data-URL for project cover art
|
||||
coverFocusY Int @default(50) // Vertical focus point 0-100 (% from top)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export const PROJECT_COLUMNS: ColumnDef[] = [
|
||||
{ key: "dates", label: "Dates", defaultVisible: true, hideable: true },
|
||||
{ key: "budget", label: "Budget", defaultVisible: false, hideable: true },
|
||||
{ key: "allocations", label: "Allocations", defaultVisible: true, hideable: true },
|
||||
{ key: "shoring", label: "Shoring", defaultVisible: false, hideable: true },
|
||||
{ key: "responsible", label: "Responsible", defaultVisible: false, hideable: true },
|
||||
];
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ export const CreateProjectBaseSchema = z.object({
|
||||
utilizationCategoryId: z.string().optional(),
|
||||
clientId: z.string().optional(),
|
||||
coverImageUrl: z.string().optional(),
|
||||
shoringThreshold: z.number().int().min(0).max(100).optional(),
|
||||
onshoreCountryCode: z.string().min(2).max(3).optional(),
|
||||
});
|
||||
|
||||
// Full schema with date-range validation
|
||||
|
||||
Reference in New Issue
Block a user