diff --git a/packages/api/src/router/computation-graph-detail.ts b/packages/api/src/router/computation-graph-detail.ts new file mode 100644 index 0000000..937bf0a --- /dev/null +++ b/packages/api/src/router/computation-graph-detail.ts @@ -0,0 +1,167 @@ +import { z } from "zod"; +import { controllerProcedure } from "../trpc.js"; + +type GraphNodeLike = { + id: string; + domain: string; +}; + +type GraphLinkLike = { + source: string; + target: string; +}; + +type ResourceGraphSnapshot = { + nodes: GraphNodeLike[]; + links: GraphLinkLike[]; + meta: { + resourceEid: string; + resourceName: string; + [key: string]: unknown; + }; +}; + +type ProjectGraphSnapshot = { + nodes: GraphNodeLike[]; + links: GraphLinkLike[]; + meta: { + projectCode: string; + projectName: string; + [key: string]: unknown; + }; +}; + +function filterGraphData< + TNode extends GraphNodeLike, + TLink extends GraphLinkLike, +>(input: { + nodes: TNode[]; + links: TLink[]; + domain?: string; + includeLinks?: boolean; +}) { + const requestedDomain = input.domain?.trim().toUpperCase(); + const nodes = requestedDomain + ? input.nodes.filter((node) => node.domain === requestedDomain) + : input.nodes; + const selectedNodeIds = new Set(nodes.map((node) => node.id)); + const links = input.includeLinks + ? input.links.filter((link) => selectedNodeIds.has(link.source) && selectedNodeIds.has(link.target)) + : []; + + return { + requestedDomain: requestedDomain ?? null, + includedLinks: input.includeLinks ?? false, + selectedNodeCount: nodes.length, + selectedLinkCount: links.length, + nodes, + ...(input.includeLinks ? { links } : {}), + }; +} + +function getAvailableDomains(nodes: TNode[]) { + return [...new Set(nodes.map((node) => node.domain))]; +} + +function formatResourceGraphDetail(input: { + resourceId: string; + graph: ResourceGraphSnapshot; + domain?: string; + includeLinks?: boolean; +}) { + return { + resource: { + id: input.resourceId, + eid: input.graph.meta.resourceEid, + displayName: input.graph.meta.resourceName, + }, + availableDomains: getAvailableDomains(input.graph.nodes), + totalNodeCount: input.graph.nodes.length, + totalLinkCount: input.graph.links.length, + ...filterGraphData({ + nodes: input.graph.nodes, + links: input.graph.links, + ...(input.domain ? { domain: input.domain } : {}), + ...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}), + }), + meta: input.graph.meta, + }; +} + +function formatProjectGraphDetail(input: { + projectId: string; + graph: ProjectGraphSnapshot; + domain?: string; + includeLinks?: boolean; +}) { + return { + project: { + id: input.projectId, + shortCode: input.graph.meta.projectCode, + name: input.graph.meta.projectName, + }, + availableDomains: getAvailableDomains(input.graph.nodes), + totalNodeCount: input.graph.nodes.length, + totalLinkCount: input.graph.links.length, + ...filterGraphData({ + nodes: input.graph.nodes, + links: input.graph.links, + ...(input.domain ? { domain: input.domain } : {}), + ...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}), + }), + meta: input.graph.meta, + }; +} + +export function createComputationGraphDetailProcedures(input: { + resourceGraphInputSchema: z.AnyZodObject; + projectGraphInputSchema: z.AnyZodObject; + readResourceGraphSnapshot: ( + ctx: any, + input: any, + ) => Promise; + readProjectGraphSnapshot: ( + ctx: any, + input: any, + ) => Promise; +}) { + const resourceGraphDetailInputSchema = input.resourceGraphInputSchema.extend({ + domain: z.string().trim().min(1).optional(), + includeLinks: z.boolean().optional(), + }); + + const projectGraphDetailInputSchema = input.projectGraphInputSchema.extend({ + domain: z.string().trim().min(1).optional(), + includeLinks: z.boolean().optional(), + }); + + return { + getResourceDataDetail: controllerProcedure + .input(resourceGraphDetailInputSchema) + .query(async ({ ctx, input: procedureInput }) => { + const graph = await input.readResourceGraphSnapshot(ctx, procedureInput); + return formatResourceGraphDetail({ + resourceId: procedureInput.resourceId, + graph, + ...(typeof procedureInput.domain === "string" ? { domain: procedureInput.domain } : {}), + ...(typeof procedureInput.includeLinks === "boolean" + ? { includeLinks: procedureInput.includeLinks } + : {}), + }); + }), + + getProjectDataDetail: controllerProcedure + .input(projectGraphDetailInputSchema) + .query(async ({ ctx, input: procedureInput }) => { + const graph = await input.readProjectGraphSnapshot(ctx, procedureInput); + return formatProjectGraphDetail({ + projectId: procedureInput.projectId, + graph, + ...(typeof procedureInput.domain === "string" ? { domain: procedureInput.domain } : {}), + ...(typeof procedureInput.includeLinks === "boolean" + ? { includeLinks: procedureInput.includeLinks } + : {}), + }); + }), + }; +} diff --git a/packages/api/src/router/computation-graph.ts b/packages/api/src/router/computation-graph.ts index 8934205..1118d4c 100644 --- a/packages/api/src/router/computation-graph.ts +++ b/packages/api/src/router/computation-graph.ts @@ -25,6 +25,7 @@ import { countEffectiveWorkingDays, loadResourceDailyAvailabilityContexts, } from "../lib/resource-capacity.js"; +import { createComputationGraphDetailProcedures } from "./computation-graph-detail.js"; // ─── Graph Types (mirrored from client for API response) ──────────────────── @@ -86,60 +87,26 @@ function sumAvailabilityHoursForDates( return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0); } -function filterGraphData< - TNode extends { id: string; domain: string }, - TLink extends { source: string; target: string }, ->(input: { - nodes: TNode[]; - links: TLink[]; - domain?: string; - includeLinks?: boolean; -}) { - const requestedDomain = input.domain?.trim().toUpperCase(); - const nodes = requestedDomain - ? input.nodes.filter((node) => node.domain === requestedDomain) - : input.nodes; - const selectedNodeIds = new Set(nodes.map((node) => node.id)); - const links = input.includeLinks - ? input.links.filter((link) => selectedNodeIds.has(link.source) && selectedNodeIds.has(link.target)) - : []; - - return { - requestedDomain: requestedDomain ?? null, - includedLinks: input.includeLinks ?? false, - selectedNodeCount: nodes.length, - selectedLinkCount: links.length, - nodes, - ...(input.includeLinks ? { links } : {}), - }; -} - -function getAvailableDomains(nodes: Array<{ domain: Domain }>): Domain[] { - return [...new Set(nodes.map((node) => node.domain))]; -} - const resourceGraphInputSchema = z.object({ resourceId: z.string(), month: z.string().regex(/^\d{4}-\d{2}$/), }); -const resourceGraphDetailInputSchema = resourceGraphInputSchema.extend({ - domain: z.string().trim().min(1).optional(), - includeLinks: z.boolean().optional(), -}); - const projectGraphInputSchema = z.object({ projectId: z.string(), }); -const projectGraphDetailInputSchema = projectGraphInputSchema.extend({ - domain: z.string().trim().min(1).optional(), - includeLinks: z.boolean().optional(), +const computationGraphDetailProcedures = createComputationGraphDetailProcedures({ + resourceGraphInputSchema, + projectGraphInputSchema, + readResourceGraphSnapshot, + readProjectGraphSnapshot, }); // ─── Router ───────────────────────────────────────────────────────────────── export const computationGraphRouter = createTRPCRouter({ + ...computationGraphDetailProcedures, /** * Resource View: SAH, Allocation, Rules, Chargeability, Budget * for a single resource in a single month. @@ -148,36 +115,12 @@ export const computationGraphRouter = createTRPCRouter({ .input(resourceGraphInputSchema) .query(({ ctx, input }) => readResourceGraphSnapshot(ctx, input)), - getResourceDataDetail: controllerProcedure - .input(resourceGraphDetailInputSchema) - .query(async ({ ctx, input }) => { - const graph = await readResourceGraphSnapshot(ctx, input); - return formatResourceGraphDetail({ - resourceId: input.resourceId, - graph, - ...(input.domain ? { domain: input.domain } : {}), - ...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}), - }); - }), - /** * Project View: Estimate, Commercial, Experience, Effort, Spread, Budget */ getProjectData: controllerProcedure .input(projectGraphInputSchema) .query(({ ctx, input }) => readProjectGraphSnapshot(ctx, input)), - - getProjectDataDetail: controllerProcedure - .input(projectGraphDetailInputSchema) - .query(async ({ ctx, input }) => { - const graph = await readProjectGraphSnapshot(ctx, input); - return formatProjectGraphDetail({ - projectId: input.projectId, - graph, - ...(input.domain ? { domain: input.domain } : {}), - ...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}), - }); - }), }); async function readResourceGraphSnapshot( @@ -1118,53 +1061,3 @@ async function readProjectGraphSnapshot( }, }; } - -function formatResourceGraphDetail(input: { - resourceId: string; - graph: Awaited>; - domain?: string; - includeLinks?: boolean; -}) { - return { - resource: { - id: input.resourceId, - eid: input.graph.meta.resourceEid, - displayName: input.graph.meta.resourceName, - }, - availableDomains: getAvailableDomains(input.graph.nodes), - totalNodeCount: input.graph.nodes.length, - totalLinkCount: input.graph.links.length, - ...filterGraphData({ - nodes: input.graph.nodes, - links: input.graph.links, - ...(input.domain ? { domain: input.domain } : {}), - ...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}), - }), - meta: input.graph.meta, - }; -} - -function formatProjectGraphDetail(input: { - projectId: string; - graph: Awaited>; - domain?: string; - includeLinks?: boolean; -}) { - return { - project: { - id: input.projectId, - shortCode: input.graph.meta.projectCode, - name: input.graph.meta.projectName, - }, - availableDomains: getAvailableDomains(input.graph.nodes), - totalNodeCount: input.graph.nodes.length, - totalLinkCount: input.graph.links.length, - ...filterGraphData({ - nodes: input.graph.nodes, - links: input.graph.links, - ...(input.domain ? { domain: input.domain } : {}), - ...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}), - }), - meta: input.graph.meta, - }; -}