Files
Nexus/packages/application/src/use-cases/dashboard/get-skill-gaps.ts
T
Hartmut 4a5edeef3e
CI / Unit Tests (pull_request) Successful in 5m46s
CI / Lint (pull_request) Failing after 3m49s
CI / E2E Tests (pull_request) Has been skipped
CI / Fresh-Linux Docker Deploy (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Failing after 35s
CI / Architecture Guardrails (pull_request) Failing after 2m14s
CI / Typecheck (pull_request) Successful in 4m22s
CI / Build (pull_request) Has been skipped
CI / Release Images (pull_request) Has been skipped
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI
- @capakraken/* → @nexus/* across 12 packages (root + 11 workspaces),
  1551 import lines migrated via codemod
- User-visible brand strings renamed (emails, page titles, PWA
  manifest, mobile header, MFA backup-codes header, tooltips, signin
  page, invite page, weekly digest, install prompt)
- TOTP issuer "CapaKraken" → "Nexus" (existing secrets still valid;
  re-enrollment relabels them in users' authenticator apps)
- Function rename: assertCapaKrakenDbTarget → assertNexusDbTarget
- LocalStorage migration shim in apps/web/src/app/layout.tsx copies
  capakraken_* → nexus_* on first load (guarded by nexus_migrated_v1
  sentinel; runs once per browser, then never again)
- Service-worker cache name capakraken-v2 → nexus-v2 with one-time
  caches.delete('capakraken-v2') from the same shim
- Email-domain fixtures @capakraken.{dev,app} → @nexus.{dev,app} in
  seed data, e2e specs, SMTP default fallback
- Dockerfile.dev / Dockerfile.prod / all .github/workflows/*.yml
  pnpm --filter @capakraken/* → @nexus/*
- README, CLAUDE.md, LEARNINGS.md, all docs/*.md, .env.example,
  tooling/deploy/.env.production.example brand sweep

Phase 1 deliberately leaves untouched (handled in Phase 3 cutover):
- PostgreSQL DB name "capakraken" and POSTGRES_USER "capakraken"
- Volume names capakraken_pgdata etc.
- Compose project name "capakraken" / "capakraken-prod"
- db-target-guard default expectedDatabase
- env-var CAPAKRAKEN_EXPECTED_DB_NAME
- Container DNS names in docker-compose.ci.yml

Quality gates green: pnpm typecheck (7/7), pnpm test:unit (7/7),
pnpm lint (0 errors), check:exports/imports/architecture all pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:10:44 +02:00

197 lines
5.5 KiB
TypeScript

import type { PrismaClient } from "@nexus/db";
export interface SkillGapRow {
skill: string;
demand: number;
supply: number;
gap: number;
}
interface SkillEntry {
name: string;
level?: number;
}
export interface SkillGapSummaryRoleGap {
role: string;
needed: number;
filled: number;
gap: number;
fillRate: number;
}
export interface SkillSupplySummaryRow {
skill: string;
resourceCount: number;
}
export interface ResourcesByRoleSummaryRow {
role: string;
count: number;
}
export interface DashboardSkillGapSummary {
roleGaps: SkillGapSummaryRoleGap[];
totalOpenPositions: number;
skillSupplyTop10: SkillSupplySummaryRow[];
resourcesByRole: ResourcesByRoleSummaryRow[];
}
export async function getDashboardSkillGaps(db: PrismaClient): Promise<SkillGapRow[]> {
// Count open demand requirements grouped by required skill (from role name)
const openDemands = await db.demandRequirement.findMany({
where: {
status: { in: ["PROPOSED", "CONFIRMED"] },
project: { status: "ACTIVE" },
},
select: {
role: true,
roleId: true,
roleEntity: { select: { name: true } },
headcount: true,
metadata: true,
},
});
// Build demand map by skill/role name
const demandMap = new Map<string, number>();
for (const d of openDemands) {
// Try to extract required skills from metadata
const meta = d.metadata as Record<string, unknown> | null;
const requiredSkills = Array.isArray(meta?.requiredSkills)
? (meta.requiredSkills as string[])
: [];
if (requiredSkills.length > 0) {
for (const skill of requiredSkills) {
const normalized = skill.trim();
if (normalized) {
demandMap.set(normalized, (demandMap.get(normalized) ?? 0) + d.headcount);
}
}
} else {
// Fall back to role name as the "skill"
const roleName = d.roleEntity?.name ?? d.role;
if (roleName) {
demandMap.set(roleName, (demandMap.get(roleName) ?? 0) + d.headcount);
}
}
}
if (demandMap.size === 0) return [];
// Count active resources with each skill at proficiency >= 3
const resources = await db.resource.findMany({
where: { isActive: true },
select: { skills: true },
});
const supplyMap = new Map<string, number>();
for (const r of resources) {
const skills = (r.skills ?? []) as unknown as SkillEntry[];
if (!Array.isArray(skills)) continue;
for (const skill of skills) {
if (!skill.name) continue;
if ((skill.level ?? 0) >= 3) {
supplyMap.set(skill.name, (supplyMap.get(skill.name) ?? 0) + 1);
}
}
}
// Build gap rows for demanded skills
const rows: SkillGapRow[] = [];
for (const [skill, demand] of demandMap) {
const supply = supplyMap.get(skill) ?? 0;
rows.push({ skill, demand, supply, gap: supply - demand });
}
// Sort by largest shortage first (most negative gap), take top 10
rows.sort((a, b) => a.gap - b.gap);
return rows.slice(0, 10);
}
export async function getDashboardSkillGapSummary(
db: PrismaClient,
): Promise<DashboardSkillGapSummary> {
const now = new Date();
const demands = await db.demandRequirement.findMany({
where: {
project: { status: { in: ["ACTIVE", "DRAFT"] } },
status: { not: "CANCELLED" },
endDate: { gte: now },
},
select: {
role: true,
headcount: true,
roleEntity: { select: { name: true } },
_count: { select: { assignments: true } },
},
});
const demandByRole = new Map<string, { needed: number; filled: number }>();
for (const demand of demands) {
const roleName = demand.roleEntity?.name ?? demand.role ?? "Unknown";
const existing = demandByRole.get(roleName) ?? { needed: 0, filled: 0 };
existing.needed += demand.headcount;
existing.filled += Math.min(demand._count.assignments, demand.headcount);
demandByRole.set(roleName, existing);
}
const resources = await db.resource.findMany({
where: { isActive: true },
select: {
skills: true,
areaRole: { select: { name: true } },
},
});
const skillSupply = new Map<string, number>();
const supplyByRole = new Map<string, number>();
for (const resource of resources) {
const rawSkills = Array.isArray(resource.skills)
? (resource.skills as Array<Record<string, unknown>>)
: [];
for (const entry of rawSkills) {
const skillName =
typeof entry.skill === "string"
? entry.skill
: typeof entry.name === "string"
? entry.name
: null;
if (!skillName) continue;
skillSupply.set(skillName.toLowerCase(), (skillSupply.get(skillName.toLowerCase()) ?? 0) + 1);
}
const roleName = resource.areaRole?.name;
if (roleName) {
supplyByRole.set(roleName, (supplyByRole.get(roleName) ?? 0) + 1);
}
}
const roleGaps = [...demandByRole.entries()]
.map(([role, { needed, filled }]) => ({
role,
needed,
filled,
gap: needed - filled,
fillRate: needed > 0 ? Math.round((filled / needed) * 100) : 100,
}))
.filter((gap) => gap.gap > 0)
.sort((left, right) => right.gap - left.gap);
return {
roleGaps,
totalOpenPositions: roleGaps.reduce((sum, gap) => sum + gap.gap, 0),
skillSupplyTop10: [...skillSupply.entries()]
.sort((left, right) => right[1] - left[1])
.slice(0, 10)
.map(([skill, resourceCount]) => ({ skill, resourceCount })),
resourcesByRole: [...supplyByRole.entries()]
.sort((left, right) => right[1] - left[1])
.map(([role, count]) => ({ role, count })),
};
}