@burdenoff/microfe-planmagnet 2026.930.22 → 2026.930.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"DependencyGraph.d.ts","sourceRoot":"","sources":["../../../src/planmagnet/dependency/DependencyGraph.tsx"],"names":[],"mappings":"AAKA;;;;;;;;GAQG;AAEH,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,gFAAgF;IAChF,EAAE,EAAE,MAAM,CAAC;IACX,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,8BAA8B;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,mBAAmB,EAAE,CAAC;IAC7B,KAAK,EAAE,mBAAmB,EAAE,CAAC;IAC7B,6FAA6F;IAC7F,eAAe,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACtC,2FAA2F;IAC3F,eAAe,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACtC,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,wDAAwD;IACxD,YAAY,EAAE,MAAM,CAAC;IACrB,wFAAwF;IACxF,SAAS,EAAE,MAAM,CAAC;CACnB;AAWD,uGAAuG;AACvG,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAG/D;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAaxD;AAED,wBAAgB,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAMjE;AA0CD,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,KAAK,EACL,eAAe,EACf,eAAe,EACf,cAAc,EACd,YAAY,EACZ,SAAS,GACV,EAAE,oBAAoB,2CA0MtB"}
1
+ {"version":3,"file":"DependencyGraph.d.ts","sourceRoot":"","sources":["../../../src/planmagnet/dependency/DependencyGraph.tsx"],"names":[],"mappings":"AAKA;;;;;;;;GAQG;AAEH,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,mBAAmB;IAClC,gFAAgF;IAChF,EAAE,EAAE,MAAM,CAAC;IACX,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,8BAA8B;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,mBAAmB,EAAE,CAAC;IAC7B,KAAK,EAAE,mBAAmB,EAAE,CAAC;IAC7B,6FAA6F;IAC7F,eAAe,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACtC,2FAA2F;IAC3F,eAAe,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACtC,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,wDAAwD;IACxD,YAAY,EAAE,MAAM,CAAC;IACrB,wFAAwF;IACxF,SAAS,EAAE,MAAM,CAAC;CACnB;AAWD,uGAAuG;AACvG,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAG/D;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAaxD;AAED,wBAAgB,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAMjE;AA0CD,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,KAAK,EACL,eAAe,EACf,eAAe,EACf,cAAc,EACd,YAAY,EACZ,SAAS,GACV,EAAE,oBAAoB,2CA6MtB"}
@@ -186,7 +186,7 @@ function y({ nodes: y, edges: b, criticalNodeIds: x, criticalEdgeIds: S, onActiv
186
186
  cx: 16,
187
187
  cy: c / 2,
188
188
  r: f,
189
- className: u.fill
189
+ style: { fill: u.color }
190
190
  }),
191
191
  /* @__PURE__ */ a("text", {
192
192
  x: 28,
@@ -210,7 +210,10 @@ function y({ nodes: y, edges: b, criticalNodeIds: x, criticalEdgeIds: S, onActiv
210
210
  className: "flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-input px-3 py-2 text-[11px] text-muted-foreground",
211
211
  children: [D.map((t) => /* @__PURE__ */ o("span", {
212
212
  className: "inline-flex items-center gap-1.5",
213
- children: [/* @__PURE__ */ a("span", { className: `h-2.5 w-2.5 rounded-full ${n(t).swatch}` }), e(t)]
213
+ children: [/* @__PURE__ */ a("span", {
214
+ className: "h-2.5 w-2.5 rounded-full",
215
+ style: { backgroundColor: n(t).color }
216
+ }), e(t)]
214
217
  }, t)), x && x.size > 0 && /* @__PURE__ */ o("span", {
215
218
  className: "inline-flex items-center gap-1.5",
216
219
  children: [/* @__PURE__ */ a("span", { className: "h-2.5 w-2.5 rounded-full border-2 border-primary bg-background" }), "Critical path"]
@@ -1 +1 @@
1
- {"version":3,"file":"DependencyGraph.js","names":[],"sources":["../../../src/planmagnet/dependency/DependencyGraph.tsx"],"sourcesContent":["import { useMemo } from 'react';\nimport { enumLabel } from '../controls/WorkItemControls';\nimport { rowLinkProps } from '../utils/rowNavigation';\nimport { statusVisual, STATUS_LEGEND_ORDER } from '../utils/statusColor';\n\n/**\n * Reusable SVG dependency graph (PDM-27). Nodes are work items, edges are `WorkItemDependency` rows, laid out in\n * layers left-to-right by longest-path topological order. Two mount points share this component:\n * - the critical-path page's \"Graph\" view (the whole project — see useProjectWorkItemsGraph.ts)\n * - the task dependency panel's one-hop neighbourhood graph (this item, its predecessors, its successors)\n *\n * The existing text list stays the accessible/no-JS-heavy fallback at every call site — this component is an\n * additional view, never a replacement for it.\n */\n\nexport interface DependencyGraphNode {\n id: string;\n key: string | null;\n title: string | null;\n status: string;\n}\n\nexport interface DependencyGraphEdge {\n /** The WorkItemDependency's own id — stable per edge, used as the React key. */\n id: string;\n predecessorId: string;\n successorId: string;\n /** A DependencyType value. */\n type: string;\n lagMinutes: number | null;\n}\n\nexport interface DependencyGraphProps {\n nodes: DependencyGraphNode[];\n edges: DependencyGraphEdge[];\n /** Node ids on the critical path (CPM chain) — emphasised with a thicker primary outline. */\n criticalNodeIds?: ReadonlySet<string>;\n /** Edge ids that connect two consecutive critical-path nodes — emphasised the same way. */\n criticalEdgeIds?: ReadonlySet<string>;\n onActivateNode: (nodeId: string) => void;\n /** Shown instead of the graph when `nodes` is empty. */\n emptyMessage: string;\n /** Accessible name for the graph region — should say what project/item it scopes to. */\n ariaLabel: string;\n}\n\nconst NODE_WIDTH = 180;\nconst NODE_HEIGHT = 56;\nconst LAYER_GAP_X = 72;\nconst NODE_GAP_Y = 16;\nconst PADDING = 20;\nconst CHIP_R = 4;\nconst TITLE_MAX_CHARS = 24;\nconst KEY_MAX_CHARS = 16;\n\n/** Shared with GanttChart.tsx, which draws the same dependency edges between bars instead of nodes. */\nexport function truncate(text: string, maxChars: number): string {\n if (text.length <= maxChars) return text;\n return `${text.slice(0, Math.max(1, maxChars - 1))}…`;\n}\n\nexport function dependencyTypeLabel(type: string): string {\n switch (type) {\n case 'FINISH_TO_START':\n return 'Finish to start';\n case 'START_TO_START':\n return 'Start to start';\n case 'FINISH_TO_FINISH':\n return 'Finish to finish';\n case 'START_TO_FINISH':\n return 'Start to finish';\n default:\n return enumLabel(type);\n }\n}\n\nexport function lagLabel(lagMinutes: number | null): string | null {\n if (!lagMinutes) return null;\n const days = lagMinutes / (60 * 24);\n const verb = lagMinutes > 0 ? 'lag' : 'lead';\n if (Number.isInteger(days)) return `${Math.abs(days)}d ${verb}`;\n return `${Math.abs(Math.round((lagMinutes / 60) * 10) / 10)}h ${verb}`;\n}\n\n/**\n * Longest-path layering with a cycle guard. Every node ends up on the layer one past the FURTHEST-along of its\n * (in-graph) predecessors — never merely the first predecessor visited — so an edge never points backward across\n * layers except when a cycle forces it (the server refuses cycles; this is defensive, not the expected path).\n *\n * `resolving` tracks ids currently on the call stack: a predecessor already being resolved (a back-edge closing a\n * cycle) is treated as layer -1 (contributing 0) rather than recursed into again, so this always terminates —\n * every id is visited at most once per still-active ancestor chain, bounded by `nodeIds.length`.\n */\nfunction computeLayers(\n nodeIds: string[],\n edges: { predecessorId: string; successorId: string }[]\n): Map<string, number> {\n const predecessorsOf = new Map<string, string[]>(nodeIds.map((id) => [id, []]));\n for (const e of edges) {\n const preds = predecessorsOf.get(e.successorId);\n if (preds && predecessorsOf.has(e.predecessorId)) preds.push(e.predecessorId);\n }\n\n const layer = new Map<string, number>();\n const resolving = new Set<string>();\n\n function resolve(id: string, depth: number): number {\n const cached = layer.get(id);\n if (cached !== undefined) return cached;\n if (resolving.has(id) || depth > nodeIds.length) return -1;\n resolving.add(id);\n const preds = predecessorsOf.get(id) ?? [];\n let max = -1;\n for (const p of preds) max = Math.max(max, resolve(p, depth + 1));\n resolving.delete(id);\n const result = max + 1;\n layer.set(id, result);\n return result;\n }\n\n for (const id of nodeIds) resolve(id, 0);\n return layer;\n}\n\nexport function DependencyGraph({\n nodes,\n edges,\n criticalNodeIds,\n criticalEdgeIds,\n onActivateNode,\n emptyMessage,\n ariaLabel,\n}: DependencyGraphProps) {\n const layout = useMemo(() => {\n const nodeIds = nodes.map((n) => n.id);\n const nodeSet = new Set(nodeIds);\n // Cross-scope edges (e.g. a dependency on a work item outside this project, or outside this item's one-hop\n // neighbourhood) have no node to draw — drop them rather than crash on a missing endpoint.\n const inScopeEdges = edges.filter(\n (e) => nodeSet.has(e.predecessorId) && nodeSet.has(e.successorId)\n );\n const layerOf = computeLayers(nodeIds, inScopeEdges);\n\n const byLayer = new Map<number, string[]>();\n for (const id of nodeIds) {\n const l = layerOf.get(id) ?? 0;\n const list = byLayer.get(l);\n if (list) list.push(id);\n else byLayer.set(l, [id]);\n }\n const layerCount = byLayer.size === 0 ? 1 : Math.max(...byLayer.keys()) + 1;\n const maxPerLayer = Math.max(1, ...Array.from(byLayer.values(), (l) => l.length));\n\n const position = new Map<string, { x: number; y: number }>();\n for (const [l, ids] of byLayer) {\n const x = PADDING + l * (NODE_WIDTH + LAYER_GAP_X);\n // Centered vertically within the tallest layer, so a short layer doesn't hug the top.\n const layerHeight = ids.length * NODE_HEIGHT + (ids.length - 1) * NODE_GAP_Y;\n const fullHeight = maxPerLayer * NODE_HEIGHT + (maxPerLayer - 1) * NODE_GAP_Y;\n const yOffset = PADDING + Math.max(0, (fullHeight - layerHeight) / 2);\n ids.forEach((id, i) => {\n position.set(id, { x, y: yOffset + i * (NODE_HEIGHT + NODE_GAP_Y) });\n });\n }\n\n const width = PADDING * 2 + layerCount * NODE_WIDTH + Math.max(0, layerCount - 1) * LAYER_GAP_X;\n const height =\n PADDING * 2 + maxPerLayer * NODE_HEIGHT + Math.max(0, maxPerLayer - 1) * NODE_GAP_Y;\n\n return { position, inScopeEdges, width, height };\n }, [nodes, edges]);\n\n const presentStatuses = useMemo(\n () => STATUS_LEGEND_ORDER.filter((s) => nodes.some((n) => n.status === s)),\n [nodes]\n );\n\n if (nodes.length === 0) {\n return (\n <div data-testid=\"dependency-graph\" className=\"p-6 text-center text-sm text-muted-foreground\">\n {emptyMessage}\n </div>\n );\n }\n\n const nodesById = new Map(nodes.map((n) => [n.id, n]));\n\n return (\n <div data-testid=\"dependency-graph\" className=\"rounded-md border border-input bg-background\">\n <p className=\"sr-only\">\n Dependency graph with {nodes.length} work item{nodes.length === 1 ? '' : 's'} and{' '}\n {layout.inScopeEdges.length} dependenc{layout.inScopeEdges.length === 1 ? 'y' : 'ies'}.\n {criticalNodeIds && criticalNodeIds.size > 0\n ? ` ${criticalNodeIds.size} item${criticalNodeIds.size === 1 ? ' is' : 's are'} on the critical path.`\n : ''}{' '}\n The list above/below gives the same information as text.\n </p>\n <div className=\"overflow-x-auto p-3\">\n {/* No role=\"img\" here: each node below is its own focusable role=\"button\" descendant, and \"img\"\n semantics would tell assistive tech to treat the whole subtree as a single flattened image. */}\n <svg\n aria-label={ariaLabel}\n width={layout.width}\n height={layout.height}\n viewBox={`0 0 ${layout.width} ${layout.height}`}\n className=\"block\"\n >\n <defs>\n <marker\n id=\"dependency-graph-arrow\"\n viewBox=\"0 0 10 10\"\n refX=\"9\"\n refY=\"5\"\n markerWidth=\"6\"\n markerHeight=\"6\"\n orient=\"auto-start-reverse\"\n className=\"text-border-strong\"\n >\n <path d=\"M0,0 L10,5 L0,10 z\" fill=\"currentColor\" />\n </marker>\n <marker\n id=\"dependency-graph-arrow-critical\"\n viewBox=\"0 0 10 10\"\n refX=\"9\"\n refY=\"5\"\n markerWidth=\"6\"\n markerHeight=\"6\"\n orient=\"auto-start-reverse\"\n className=\"text-primary\"\n >\n <path d=\"M0,0 L10,5 L0,10 z\" fill=\"currentColor\" />\n </marker>\n </defs>\n\n {layout.inScopeEdges.map((edge) => {\n const from = layout.position.get(edge.predecessorId);\n const to = layout.position.get(edge.successorId);\n if (!from || !to) return null;\n const x1 = from.x + NODE_WIDTH;\n const y1 = from.y + NODE_HEIGHT / 2;\n const x2 = to.x;\n const y2 = to.y + NODE_HEIGHT / 2;\n const midX = (x1 + x2) / 2;\n const critical = criticalEdgeIds?.has(edge.id) ?? false;\n const typeLabel =\n edge.type === 'FINISH_TO_START' ? null : dependencyTypeLabel(edge.type);\n const lag = lagLabel(edge.lagMinutes);\n const label = [typeLabel, lag].filter((v): v is string => !!v).join(' · ');\n const predecessor = nodesById.get(edge.predecessorId);\n const successor = nodesById.get(edge.successorId);\n return (\n <g key={edge.id} data-testid=\"dependency-graph-edge\">\n <title>\n {predecessor?.key ?? edge.predecessorId} {dependencyTypeLabel(edge.type)}{' '}\n {successor?.key ?? edge.successorId}\n {lag ? ` (${lag})` : ''}\n </title>\n <path\n d={`M ${x1},${y1} C ${midX},${y1} ${midX},${y2} ${x2},${y2}`}\n fill=\"none\"\n strokeWidth={critical ? 2.5 : 1.5}\n className={critical ? 'text-primary' : 'text-border-strong'}\n stroke=\"currentColor\"\n markerEnd={`url(#${critical ? 'dependency-graph-arrow-critical' : 'dependency-graph-arrow'})`}\n />\n {label && (\n <text\n x={midX}\n y={(y1 + y2) / 2 - 4}\n textAnchor=\"middle\"\n className=\"fill-muted-foreground text-[9px]\"\n >\n {label}\n </text>\n )}\n </g>\n );\n })}\n\n {nodes.map((node) => {\n const pos = layout.position.get(node.id);\n if (!pos) return null;\n const critical = criticalNodeIds?.has(node.id) ?? false;\n const visual = statusVisual(node.status);\n const title = node.title ?? 'Untitled work item';\n const keyLabel = node.key ?? '';\n const accessibleLabel = `${keyLabel ? `${keyLabel} — ` : ''}${title} (${enumLabel(node.status)})${critical ? ', on critical path' : ''}`;\n // ROW_LINK_CLASS (in the rowLinkProps spread below) rings via box-shadow, which SVG elements\n // outside a <foreignObject> don't paint — the explicit className after the spread overrides it\n // with an outline-based focus style, which they do.\n return (\n <g\n key={node.id}\n data-testid=\"dependency-graph-node\"\n transform={`translate(${pos.x}, ${pos.y})`}\n {...rowLinkProps<SVGGElement>(() => onActivateNode(node.id), accessibleLabel)}\n className=\"cursor-pointer focus:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2\"\n >\n <title>{accessibleLabel}</title>\n <rect\n width={NODE_WIDTH}\n height={NODE_HEIGHT}\n rx={8}\n className={`fill-background ${critical ? 'stroke-primary' : 'stroke-input'}`}\n strokeWidth={critical ? 2 : 1}\n />\n <circle cx={16} cy={NODE_HEIGHT / 2} r={CHIP_R} className={visual.fill} />\n <text x={28} y={22} className=\"fill-muted-foreground text-[10px] font-medium\">\n {truncate(keyLabel, KEY_MAX_CHARS)}\n </text>\n <text x={28} y={38} className=\"fill-foreground text-[11px] font-medium\">\n {truncate(title, TITLE_MAX_CHARS)}\n </text>\n </g>\n );\n })}\n </svg>\n </div>\n <div className=\"flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-input px-3 py-2 text-[11px] text-muted-foreground\">\n {presentStatuses.map((status) => (\n <span key={status} className=\"inline-flex items-center gap-1.5\">\n <span className={`h-2.5 w-2.5 rounded-full ${statusVisual(status).swatch}`} />\n {enumLabel(status)}\n </span>\n ))}\n {criticalNodeIds && criticalNodeIds.size > 0 && (\n <span className=\"inline-flex items-center gap-1.5\">\n <span className=\"h-2.5 w-2.5 rounded-full border-2 border-primary bg-background\" />\n Critical path\n </span>\n )}\n </div>\n </div>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"DependencyGraph.js","names":[],"sources":["../../../src/planmagnet/dependency/DependencyGraph.tsx"],"sourcesContent":["import { useMemo } from 'react';\nimport { enumLabel } from '../controls/WorkItemControls';\nimport { rowLinkProps } from '../utils/rowNavigation';\nimport { statusVisual, STATUS_LEGEND_ORDER } from '../utils/statusColor';\n\n/**\n * Reusable SVG dependency graph (PDM-27). Nodes are work items, edges are `WorkItemDependency` rows, laid out in\n * layers left-to-right by longest-path topological order. Two mount points share this component:\n * - the critical-path page's \"Graph\" view (the whole project — see useProjectWorkItemsGraph.ts)\n * - the task dependency panel's one-hop neighbourhood graph (this item, its predecessors, its successors)\n *\n * The existing text list stays the accessible/no-JS-heavy fallback at every call site — this component is an\n * additional view, never a replacement for it.\n */\n\nexport interface DependencyGraphNode {\n id: string;\n key: string | null;\n title: string | null;\n status: string;\n}\n\nexport interface DependencyGraphEdge {\n /** The WorkItemDependency's own id — stable per edge, used as the React key. */\n id: string;\n predecessorId: string;\n successorId: string;\n /** A DependencyType value. */\n type: string;\n lagMinutes: number | null;\n}\n\nexport interface DependencyGraphProps {\n nodes: DependencyGraphNode[];\n edges: DependencyGraphEdge[];\n /** Node ids on the critical path (CPM chain) — emphasised with a thicker primary outline. */\n criticalNodeIds?: ReadonlySet<string>;\n /** Edge ids that connect two consecutive critical-path nodes — emphasised the same way. */\n criticalEdgeIds?: ReadonlySet<string>;\n onActivateNode: (nodeId: string) => void;\n /** Shown instead of the graph when `nodes` is empty. */\n emptyMessage: string;\n /** Accessible name for the graph region — should say what project/item it scopes to. */\n ariaLabel: string;\n}\n\nconst NODE_WIDTH = 180;\nconst NODE_HEIGHT = 56;\nconst LAYER_GAP_X = 72;\nconst NODE_GAP_Y = 16;\nconst PADDING = 20;\nconst CHIP_R = 4;\nconst TITLE_MAX_CHARS = 24;\nconst KEY_MAX_CHARS = 16;\n\n/** Shared with GanttChart.tsx, which draws the same dependency edges between bars instead of nodes. */\nexport function truncate(text: string, maxChars: number): string {\n if (text.length <= maxChars) return text;\n return `${text.slice(0, Math.max(1, maxChars - 1))}…`;\n}\n\nexport function dependencyTypeLabel(type: string): string {\n switch (type) {\n case 'FINISH_TO_START':\n return 'Finish to start';\n case 'START_TO_START':\n return 'Start to start';\n case 'FINISH_TO_FINISH':\n return 'Finish to finish';\n case 'START_TO_FINISH':\n return 'Start to finish';\n default:\n return enumLabel(type);\n }\n}\n\nexport function lagLabel(lagMinutes: number | null): string | null {\n if (!lagMinutes) return null;\n const days = lagMinutes / (60 * 24);\n const verb = lagMinutes > 0 ? 'lag' : 'lead';\n if (Number.isInteger(days)) return `${Math.abs(days)}d ${verb}`;\n return `${Math.abs(Math.round((lagMinutes / 60) * 10) / 10)}h ${verb}`;\n}\n\n/**\n * Longest-path layering with a cycle guard. Every node ends up on the layer one past the FURTHEST-along of its\n * (in-graph) predecessors — never merely the first predecessor visited — so an edge never points backward across\n * layers except when a cycle forces it (the server refuses cycles; this is defensive, not the expected path).\n *\n * `resolving` tracks ids currently on the call stack: a predecessor already being resolved (a back-edge closing a\n * cycle) is treated as layer -1 (contributing 0) rather than recursed into again, so this always terminates —\n * every id is visited at most once per still-active ancestor chain, bounded by `nodeIds.length`.\n */\nfunction computeLayers(\n nodeIds: string[],\n edges: { predecessorId: string; successorId: string }[]\n): Map<string, number> {\n const predecessorsOf = new Map<string, string[]>(nodeIds.map((id) => [id, []]));\n for (const e of edges) {\n const preds = predecessorsOf.get(e.successorId);\n if (preds && predecessorsOf.has(e.predecessorId)) preds.push(e.predecessorId);\n }\n\n const layer = new Map<string, number>();\n const resolving = new Set<string>();\n\n function resolve(id: string, depth: number): number {\n const cached = layer.get(id);\n if (cached !== undefined) return cached;\n if (resolving.has(id) || depth > nodeIds.length) return -1;\n resolving.add(id);\n const preds = predecessorsOf.get(id) ?? [];\n let max = -1;\n for (const p of preds) max = Math.max(max, resolve(p, depth + 1));\n resolving.delete(id);\n const result = max + 1;\n layer.set(id, result);\n return result;\n }\n\n for (const id of nodeIds) resolve(id, 0);\n return layer;\n}\n\nexport function DependencyGraph({\n nodes,\n edges,\n criticalNodeIds,\n criticalEdgeIds,\n onActivateNode,\n emptyMessage,\n ariaLabel,\n}: DependencyGraphProps) {\n const layout = useMemo(() => {\n const nodeIds = nodes.map((n) => n.id);\n const nodeSet = new Set(nodeIds);\n // Cross-scope edges (e.g. a dependency on a work item outside this project, or outside this item's one-hop\n // neighbourhood) have no node to draw — drop them rather than crash on a missing endpoint.\n const inScopeEdges = edges.filter(\n (e) => nodeSet.has(e.predecessorId) && nodeSet.has(e.successorId)\n );\n const layerOf = computeLayers(nodeIds, inScopeEdges);\n\n const byLayer = new Map<number, string[]>();\n for (const id of nodeIds) {\n const l = layerOf.get(id) ?? 0;\n const list = byLayer.get(l);\n if (list) list.push(id);\n else byLayer.set(l, [id]);\n }\n const layerCount = byLayer.size === 0 ? 1 : Math.max(...byLayer.keys()) + 1;\n const maxPerLayer = Math.max(1, ...Array.from(byLayer.values(), (l) => l.length));\n\n const position = new Map<string, { x: number; y: number }>();\n for (const [l, ids] of byLayer) {\n const x = PADDING + l * (NODE_WIDTH + LAYER_GAP_X);\n // Centered vertically within the tallest layer, so a short layer doesn't hug the top.\n const layerHeight = ids.length * NODE_HEIGHT + (ids.length - 1) * NODE_GAP_Y;\n const fullHeight = maxPerLayer * NODE_HEIGHT + (maxPerLayer - 1) * NODE_GAP_Y;\n const yOffset = PADDING + Math.max(0, (fullHeight - layerHeight) / 2);\n ids.forEach((id, i) => {\n position.set(id, { x, y: yOffset + i * (NODE_HEIGHT + NODE_GAP_Y) });\n });\n }\n\n const width = PADDING * 2 + layerCount * NODE_WIDTH + Math.max(0, layerCount - 1) * LAYER_GAP_X;\n const height =\n PADDING * 2 + maxPerLayer * NODE_HEIGHT + Math.max(0, maxPerLayer - 1) * NODE_GAP_Y;\n\n return { position, inScopeEdges, width, height };\n }, [nodes, edges]);\n\n const presentStatuses = useMemo(\n () => STATUS_LEGEND_ORDER.filter((s) => nodes.some((n) => n.status === s)),\n [nodes]\n );\n\n if (nodes.length === 0) {\n return (\n <div data-testid=\"dependency-graph\" className=\"p-6 text-center text-sm text-muted-foreground\">\n {emptyMessage}\n </div>\n );\n }\n\n const nodesById = new Map(nodes.map((n) => [n.id, n]));\n\n return (\n <div data-testid=\"dependency-graph\" className=\"rounded-md border border-input bg-background\">\n <p className=\"sr-only\">\n Dependency graph with {nodes.length} work item{nodes.length === 1 ? '' : 's'} and{' '}\n {layout.inScopeEdges.length} dependenc{layout.inScopeEdges.length === 1 ? 'y' : 'ies'}.\n {criticalNodeIds && criticalNodeIds.size > 0\n ? ` ${criticalNodeIds.size} item${criticalNodeIds.size === 1 ? ' is' : 's are'} on the critical path.`\n : ''}{' '}\n The list above/below gives the same information as text.\n </p>\n <div className=\"overflow-x-auto p-3\">\n {/* No role=\"img\" here: each node below is its own focusable role=\"button\" descendant, and \"img\"\n semantics would tell assistive tech to treat the whole subtree as a single flattened image. */}\n <svg\n aria-label={ariaLabel}\n width={layout.width}\n height={layout.height}\n viewBox={`0 0 ${layout.width} ${layout.height}`}\n className=\"block\"\n >\n <defs>\n <marker\n id=\"dependency-graph-arrow\"\n viewBox=\"0 0 10 10\"\n refX=\"9\"\n refY=\"5\"\n markerWidth=\"6\"\n markerHeight=\"6\"\n orient=\"auto-start-reverse\"\n className=\"text-border-strong\"\n >\n <path d=\"M0,0 L10,5 L0,10 z\" fill=\"currentColor\" />\n </marker>\n <marker\n id=\"dependency-graph-arrow-critical\"\n viewBox=\"0 0 10 10\"\n refX=\"9\"\n refY=\"5\"\n markerWidth=\"6\"\n markerHeight=\"6\"\n orient=\"auto-start-reverse\"\n className=\"text-primary\"\n >\n <path d=\"M0,0 L10,5 L0,10 z\" fill=\"currentColor\" />\n </marker>\n </defs>\n\n {layout.inScopeEdges.map((edge) => {\n const from = layout.position.get(edge.predecessorId);\n const to = layout.position.get(edge.successorId);\n if (!from || !to) return null;\n const x1 = from.x + NODE_WIDTH;\n const y1 = from.y + NODE_HEIGHT / 2;\n const x2 = to.x;\n const y2 = to.y + NODE_HEIGHT / 2;\n const midX = (x1 + x2) / 2;\n const critical = criticalEdgeIds?.has(edge.id) ?? false;\n const typeLabel =\n edge.type === 'FINISH_TO_START' ? null : dependencyTypeLabel(edge.type);\n const lag = lagLabel(edge.lagMinutes);\n const label = [typeLabel, lag].filter((v): v is string => !!v).join(' · ');\n const predecessor = nodesById.get(edge.predecessorId);\n const successor = nodesById.get(edge.successorId);\n return (\n <g key={edge.id} data-testid=\"dependency-graph-edge\">\n <title>\n {predecessor?.key ?? edge.predecessorId} {dependencyTypeLabel(edge.type)}{' '}\n {successor?.key ?? edge.successorId}\n {lag ? ` (${lag})` : ''}\n </title>\n <path\n d={`M ${x1},${y1} C ${midX},${y1} ${midX},${y2} ${x2},${y2}`}\n fill=\"none\"\n strokeWidth={critical ? 2.5 : 1.5}\n className={critical ? 'text-primary' : 'text-border-strong'}\n stroke=\"currentColor\"\n markerEnd={`url(#${critical ? 'dependency-graph-arrow-critical' : 'dependency-graph-arrow'})`}\n />\n {label && (\n <text\n x={midX}\n y={(y1 + y2) / 2 - 4}\n textAnchor=\"middle\"\n className=\"fill-muted-foreground text-[9px]\"\n >\n {label}\n </text>\n )}\n </g>\n );\n })}\n\n {nodes.map((node) => {\n const pos = layout.position.get(node.id);\n if (!pos) return null;\n const critical = criticalNodeIds?.has(node.id) ?? false;\n const visual = statusVisual(node.status);\n const title = node.title ?? 'Untitled work item';\n const keyLabel = node.key ?? '';\n const accessibleLabel = `${keyLabel ? `${keyLabel} — ` : ''}${title} (${enumLabel(node.status)})${critical ? ', on critical path' : ''}`;\n // ROW_LINK_CLASS (in the rowLinkProps spread below) rings via box-shadow, which SVG elements\n // outside a <foreignObject> don't paint — the explicit className after the spread overrides it\n // with an outline-based focus style, which they do.\n return (\n <g\n key={node.id}\n data-testid=\"dependency-graph-node\"\n transform={`translate(${pos.x}, ${pos.y})`}\n {...rowLinkProps<SVGGElement>(() => onActivateNode(node.id), accessibleLabel)}\n className=\"cursor-pointer focus:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2\"\n >\n <title>{accessibleLabel}</title>\n <rect\n width={NODE_WIDTH}\n height={NODE_HEIGHT}\n rx={8}\n className={`fill-background ${critical ? 'stroke-primary' : 'stroke-input'}`}\n strokeWidth={critical ? 2 : 1}\n />\n <circle cx={16} cy={NODE_HEIGHT / 2} r={CHIP_R} style={{ fill: visual.color }} />\n <text x={28} y={22} className=\"fill-muted-foreground text-[10px] font-medium\">\n {truncate(keyLabel, KEY_MAX_CHARS)}\n </text>\n <text x={28} y={38} className=\"fill-foreground text-[11px] font-medium\">\n {truncate(title, TITLE_MAX_CHARS)}\n </text>\n </g>\n );\n })}\n </svg>\n </div>\n <div className=\"flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-input px-3 py-2 text-[11px] text-muted-foreground\">\n {presentStatuses.map((status) => (\n <span key={status} className=\"inline-flex items-center gap-1.5\">\n <span\n className=\"h-2.5 w-2.5 rounded-full\"\n style={{ backgroundColor: statusVisual(status).color }}\n />\n {enumLabel(status)}\n </span>\n ))}\n {criticalNodeIds && criticalNodeIds.size > 0 && (\n <span className=\"inline-flex items-center gap-1.5\">\n <span className=\"h-2.5 w-2.5 rounded-full border-2 border-primary bg-background\" />\n Critical path\n </span>\n )}\n </div>\n </div>\n );\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"GanttChart.d.ts","sourceRoot":"","sources":["../../../src/planmagnet/dependency/GanttChart.tsx"],"names":[],"mappings":"AAKA;;;;;;;;;;;GAWG;AAEH,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,YAAY,EAAE,eAAe,EAAE,CAAC;CACjC;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,+EAA+E;IAC/E,WAAW,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAClC,2GAA2G;IAC3G,eAAe,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACtC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IACvB,aAAa,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,4EAA4E;IAC5E,KAAK,CAAC,EAAE,IAAI,CAAC;CACd;AAsED,wBAAgB,UAAU,CAAC,EACzB,KAAK,EACL,WAAW,EACX,eAAe,EACf,IAAI,EACJ,aAAa,EACb,KAAK,GACN,EAAE,eAAe,2CA6SjB"}
1
+ {"version":3,"file":"GanttChart.d.ts","sourceRoot":"","sources":["../../../src/planmagnet/dependency/GanttChart.tsx"],"names":[],"mappings":"AAKA;;;;;;;;;;;GAWG;AAEH,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,YAAY,EAAE,eAAe,EAAE,CAAC;CACjC;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,+EAA+E;IAC/E,WAAW,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAClC,2GAA2G;IAC3G,eAAe,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACtC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IACvB,aAAa,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,4EAA4E;IAC5E,KAAK,CAAC,EAAE,IAAI,CAAC;CACd;AAsED,wBAAgB,UAAU,CAAC,EACzB,KAAK,EACL,WAAW,EACX,eAAe,EACf,IAAI,EACJ,aAAa,EACb,KAAK,GACN,EAAE,eAAe,2CAmTjB"}
@@ -200,13 +200,14 @@ function T({ items: b, criticalIds: T, criticalEdgeIds: E, zoom: D, onActivateRo
200
200
  x2: A.x(e.day),
201
201
  y1: 0,
202
202
  y2: A.plotHeight,
203
- className: "text-chart-grid",
203
+ style: { color: "var(--color-chart-grid)" },
204
204
  stroke: "currentColor",
205
205
  strokeWidth: 1
206
206
  }), /* @__PURE__ */ s("text", {
207
207
  x: A.x(e.day) + 3,
208
208
  y: p - 10,
209
- className: "fill-chart-axis text-[10px]",
209
+ className: "text-[10px]",
210
+ style: { fill: "var(--color-chart-axis)" },
210
211
  children: e.label
211
212
  })] }, e.day)),
212
213
  /* @__PURE__ */ s("line", {
@@ -250,7 +251,8 @@ function T({ items: b, criticalIds: T, criticalEdgeIds: E, zoom: D, onActivateRo
250
251
  width: h,
251
252
  height: h,
252
253
  transform: `rotate(45 ${e} ${i})`,
253
- className: `${o.fill} ${a ? "stroke-primary" : "stroke-input"}`,
254
+ className: a ? "stroke-primary" : "stroke-input",
255
+ style: { fill: o.color },
254
256
  strokeWidth: a ? 2 : 1
255
257
  })]
256
258
  }, t.item.id);
@@ -264,7 +266,8 @@ function T({ items: b, criticalIds: T, criticalEdgeIds: E, zoom: D, onActivateRo
264
266
  width: _,
265
267
  height: m,
266
268
  rx: 4,
267
- className: `${o.fill} ${a ? "stroke-primary" : "stroke-input"}`,
269
+ className: a ? "stroke-primary" : "stroke-input",
270
+ style: { fill: o.color },
268
271
  strokeWidth: a ? 2 : 1
269
272
  })]
270
273
  }, t.item.id);
@@ -278,7 +281,10 @@ function T({ items: b, criticalIds: T, criticalEdgeIds: E, zoom: D, onActivateRo
278
281
  children: [
279
282
  j.map((t) => /* @__PURE__ */ c("span", {
280
283
  className: "inline-flex items-center gap-1.5",
281
- children: [/* @__PURE__ */ s("span", { className: `h-2.5 w-2.5 rounded-full ${n(t).swatch}` }), e(t)]
284
+ children: [/* @__PURE__ */ s("span", {
285
+ className: "h-2.5 w-2.5 rounded-full",
286
+ style: { backgroundColor: n(t).color }
287
+ }), e(t)]
282
288
  }, t)),
283
289
  /* @__PURE__ */ c("span", {
284
290
  className: "inline-flex items-center gap-1.5",
@@ -1 +1 @@
1
- {"version":3,"file":"GanttChart.js","names":[],"sources":["../../../src/planmagnet/dependency/GanttChart.tsx"],"sourcesContent":["import { useMemo } from 'react';\nimport { enumLabel } from '../controls/WorkItemControls';\nimport { statusVisual, STATUS_LEGEND_ORDER } from '../utils/statusColor';\nimport { dependencyTypeLabel, lagLabel, truncate } from './DependencyGraph';\n\n/**\n * Gantt timeline (ANR-24) for one project's work items. Plain SVG (recharts has no Gantt/timeline mark) — see\n * ProjectsGanttPagePage.tsx for data fetching, project choice, and the week/month zoom control this renders to.\n *\n * SCHEDULE SOURCE: `WorkItem` has no persisted start date in the current schema (`graphql/wspace.graphql` —\n * only `dueDate` and `estimateMinutes`). A bar's start is therefore DERIVED, backward from the due date, as\n * `dueDate - estimateMinutes`: an item with a due date AND a positive estimate draws as a bar; an item with a\n * due date but no (or a zero/negative) estimate draws as a milestone diamond, since there is no span to show; an\n * item with no due date at all has no anchor to place it on the axis and is counted in the unscheduled note\n * instead. This is called out again in the delivery notes — it is a real deviation from a literal \"has its own\n * start date\" reading, forced by the schema as it stands today.\n */\n\nexport interface GanttDependency {\n id: string;\n successorId: string;\n type: string;\n lagMinutes: number | null;\n}\n\nexport interface GanttWorkItem {\n id: string;\n key: string;\n title: string;\n status: string;\n dueDate: string | null;\n estimateMinutes: number | null;\n dependencies: GanttDependency[];\n}\n\nexport interface GanttChartProps {\n items: GanttWorkItem[];\n /** Work item ids on the critical path — their bars/diamonds are emphasised. */\n criticalIds?: ReadonlySet<string>;\n /** WorkItemDependency ids connecting two CONSECUTIVE critical-path items — their arrows are emphasised. */\n criticalEdgeIds?: ReadonlySet<string>;\n zoom: 'week' | 'month';\n onActivateRow: (id: string) => void;\n /** Injectable for predictable output; defaults to the real current time. */\n today?: Date;\n}\n\nconst MS_PER_DAY = 86_400_000;\nconst LABEL_COL_WIDTH = 224;\nconst ROW_HEIGHT = 28;\nconst ROW_GAP = 8;\nconst ROW_STEP = ROW_HEIGHT + ROW_GAP;\nconst AXIS_HEIGHT = 28;\nconst BAR_HEIGHT = 18;\nconst DIAMOND_SIZE = 12;\nconst SVG_PADDING = 12;\nconst DOMAIN_PAD_DAYS = 3;\n\nconst ZOOM_PX_PER_DAY: Record<GanttChartProps['zoom'], number> = { week: 28, month: 6 };\n\nfunction utcDayNumber(d: Date): number {\n return Math.floor(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) / MS_PER_DAY);\n}\n\nfunction dayNumberFromIso(iso: string): number {\n return utcDayNumber(new Date(iso));\n}\n\nfunction formatShortDate(dayNum: number): string {\n const d = new Date(dayNum * MS_PER_DAY);\n return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' });\n}\n\nfunction formatMonth(dayNum: number): string {\n const d = new Date(dayNum * MS_PER_DAY);\n return d.toLocaleDateString(undefined, { month: 'short', year: 'numeric', timeZone: 'UTC' });\n}\n\ntype ScheduledRow =\n | {\n kind: 'bar';\n item: GanttWorkItem;\n startDay: number;\n dueDay: number;\n sortDay: number;\n }\n | {\n kind: 'milestone';\n item: GanttWorkItem;\n dueDay: number;\n sortDay: number;\n };\n\nfunction scheduleItem(item: GanttWorkItem): ScheduledRow | null {\n if (!item.dueDate) return null;\n const dueDay = dayNumberFromIso(item.dueDate);\n if (item.estimateMinutes && item.estimateMinutes > 0) {\n const estimateDays = Math.max(1, Math.ceil(item.estimateMinutes / (60 * 24)));\n const startDay = dueDay - (estimateDays - 1);\n return { kind: 'bar', item, startDay, dueDay, sortDay: startDay };\n }\n return { kind: 'milestone', item, dueDay, sortDay: dueDay };\n}\n\n/**\n * Anchor x (in fractional day units, fed straight into `x()`) for one end of a row's mark. Must match the\n * geometry the row actually renders at: a bar spans a full day-width per day, so its true right edge sits one\n * day past `dueDay` (see `x2` at the bar render site below), and a milestone diamond is centered half a\n * day-width past `dueDay` (see `cx` at the milestone render site below) — not at `dueDay` itself either end.\n */\nfunction anchorDay(row: ScheduledRow, end: 'start' | 'end'): number {\n if (row.kind === 'milestone') return row.dueDay + 0.5;\n return end === 'start' ? row.startDay : row.dueDay + 1;\n}\n\nexport function GanttChart({\n items,\n criticalIds,\n criticalEdgeIds,\n zoom,\n onActivateRow,\n today,\n}: GanttChartProps) {\n const layout = useMemo(() => {\n const scheduled: ScheduledRow[] = [];\n const unscheduled: GanttWorkItem[] = [];\n for (const item of items) {\n const row = scheduleItem(item);\n if (row) scheduled.push(row);\n else unscheduled.push(item);\n }\n scheduled.sort((a, b) => a.sortDay - b.sortDay || a.item.key.localeCompare(b.item.key));\n\n const rowByItemId = new Map(scheduled.map((r, i) => [r.item.id, { row: r, index: i }]));\n const todayDay = utcDayNumber(today ?? new Date());\n\n const days = scheduled.flatMap((r) => (r.kind === 'bar' ? [r.startDay, r.dueDay] : [r.dueDay]));\n days.push(todayDay);\n const domainStart = Math.min(...days) - DOMAIN_PAD_DAYS;\n const domainEnd = Math.max(...days) + DOMAIN_PAD_DAYS;\n const pxPerDay = ZOOM_PX_PER_DAY[zoom];\n const x = (day: number) => SVG_PADDING + (day - domainStart) * pxPerDay;\n\n const plotWidth = SVG_PADDING * 2 + (domainEnd - domainStart + 1) * pxPerDay;\n const plotHeight = AXIS_HEIGHT + Math.max(1, scheduled.length) * ROW_STEP + SVG_PADDING;\n\n const ticks: { day: number; label: string }[] = [];\n for (let d = domainStart; d <= domainEnd; d++) {\n const date = new Date(d * MS_PER_DAY);\n if (zoom === 'week' && date.getUTCDay() === 1)\n ticks.push({ day: d, label: formatShortDate(d) });\n if (zoom === 'month' && date.getUTCDate() === 1)\n ticks.push({ day: d, label: formatMonth(d) });\n }\n\n const arrows = scheduled.flatMap(({ item: predecessorItem }) => {\n const from = rowByItemId.get(predecessorItem.id);\n if (!from) return [];\n return predecessorItem.dependencies.flatMap((dep) => {\n const to = rowByItemId.get(dep.successorId);\n if (!to) return [];\n const fromEnd =\n dep.type === 'START_TO_START' || dep.type === 'START_TO_FINISH' ? 'start' : 'end';\n const toEnd =\n dep.type === 'FINISH_TO_FINISH' || dep.type === 'START_TO_FINISH' ? 'end' : 'start';\n return [\n {\n id: dep.id,\n type: dep.type,\n lagMinutes: dep.lagMinutes,\n x1: x(anchorDay(from.row, fromEnd)),\n y1: AXIS_HEIGHT + from.index * ROW_STEP + ROW_HEIGHT / 2,\n x2: x(anchorDay(to.row, toEnd)),\n y2: AXIS_HEIGHT + to.index * ROW_STEP + ROW_HEIGHT / 2,\n },\n ];\n });\n });\n\n return {\n scheduled,\n unscheduled,\n todayX: x(todayDay),\n ticks,\n arrows,\n plotWidth,\n plotHeight,\n x,\n };\n }, [items, zoom, today]);\n\n const presentStatuses = useMemo(\n () => STATUS_LEGEND_ORDER.filter((s) => items.some((i) => i.status === s)),\n [items]\n );\n\n return (\n <div className=\"rounded-md border border-input bg-background\" data-testid=\"gantt-chart\">\n <p className=\"sr-only\">\n Gantt timeline with {layout.scheduled.length} scheduled work item\n {layout.scheduled.length === 1 ? '' : 's'}\n {layout.unscheduled.length > 0\n ? ` and ${layout.unscheduled.length} unscheduled item${layout.unscheduled.length === 1 ? '' : 's'} listed below the chart`\n : ''}\n . Row labels open the task detail page.\n </p>\n <div className=\"flex\">\n <div style={{ width: LABEL_COL_WIDTH }} className=\"shrink-0 border-r border-input\">\n <div style={{ height: AXIS_HEIGHT }} className=\"border-b border-input\" />\n {layout.scheduled.map((row) => {\n const critical = criticalIds?.has(row.item.id) ?? false;\n return (\n <div\n key={row.item.id}\n data-testid=\"gantt-row\"\n style={{ height: ROW_STEP }}\n className=\"flex items-center px-2\"\n >\n <button\n type=\"button\"\n onClick={() => onActivateRow(row.item.id)}\n className={`min-w-0 truncate text-left text-xs hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary ${critical ? 'font-semibold text-primary' : 'text-foreground'}`}\n title={`${row.item.key} ${row.item.title}`}\n >\n {row.item.key} <span className=\"font-normal\">{truncate(row.item.title, 28)}</span>\n </button>\n </div>\n );\n })}\n {layout.scheduled.length === 0 && (\n <p className=\"px-2 py-3 text-xs text-muted-foreground\">No scheduled work items.</p>\n )}\n </div>\n\n <div className=\"overflow-x-auto\">\n <svg\n aria-label=\"Project timeline\"\n width={layout.plotWidth}\n height={layout.plotHeight}\n viewBox={`0 0 ${layout.plotWidth} ${layout.plotHeight}`}\n className=\"block\"\n >\n <defs>\n <marker\n id=\"gantt-arrow\"\n viewBox=\"0 0 10 10\"\n refX=\"9\"\n refY=\"5\"\n markerWidth=\"6\"\n markerHeight=\"6\"\n orient=\"auto-start-reverse\"\n className=\"text-border-strong\"\n >\n <path d=\"M0,0 L10,5 L0,10 z\" fill=\"currentColor\" />\n </marker>\n <marker\n id=\"gantt-arrow-critical\"\n viewBox=\"0 0 10 10\"\n refX=\"9\"\n refY=\"5\"\n markerWidth=\"6\"\n markerHeight=\"6\"\n orient=\"auto-start-reverse\"\n className=\"text-primary\"\n >\n <path d=\"M0,0 L10,5 L0,10 z\" fill=\"currentColor\" />\n </marker>\n </defs>\n\n {layout.ticks.map((tick) => (\n <g key={tick.day}>\n <line\n x1={layout.x(tick.day)}\n x2={layout.x(tick.day)}\n y1={0}\n y2={layout.plotHeight}\n className=\"text-chart-grid\"\n stroke=\"currentColor\"\n strokeWidth={1}\n />\n <text\n x={layout.x(tick.day) + 3}\n y={AXIS_HEIGHT - 10}\n className=\"fill-chart-axis text-[10px]\"\n >\n {tick.label}\n </text>\n </g>\n ))}\n\n <line\n x1={layout.todayX}\n x2={layout.todayX}\n y1={0}\n y2={layout.plotHeight}\n className=\"text-status-info-bg\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeDasharray=\"4 3\"\n />\n <text\n x={layout.todayX + 4}\n y={12}\n className=\"fill-status-info-text text-[10px] font-medium\"\n >\n Today\n </text>\n\n {layout.arrows.map((arrow) => {\n const midX = (arrow.x1 + arrow.x2) / 2;\n const critical = criticalEdgeIds?.has(arrow.id) ?? false;\n return (\n <path\n key={arrow.id}\n data-testid=\"gantt-arrow\"\n d={`M ${arrow.x1},${arrow.y1} C ${midX},${arrow.y1} ${midX},${arrow.y2} ${arrow.x2},${arrow.y2}`}\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={critical ? 2 : 1.25}\n className={critical ? 'text-primary' : 'text-border-strong'}\n markerEnd={`url(#${critical ? 'gantt-arrow-critical' : 'gantt-arrow'})`}\n >\n <title>\n {dependencyTypeLabel(arrow.type)}\n {lagLabel(arrow.lagMinutes) ? ` (${lagLabel(arrow.lagMinutes)})` : ''}\n </title>\n </path>\n );\n })}\n\n {layout.scheduled.map((row, index) => {\n const y = AXIS_HEIGHT + index * ROW_STEP + ROW_HEIGHT / 2;\n const critical = criticalIds?.has(row.item.id) ?? false;\n const visual = statusVisual(row.item.status);\n const label = `${row.item.key} — ${row.item.title} (${enumLabel(row.item.status)})${critical ? ', on critical path' : ''}`;\n if (row.kind === 'milestone') {\n const cx = layout.x(row.dueDay) + ZOOM_PX_PER_DAY[zoom] / 2;\n const r = DIAMOND_SIZE / 2;\n return (\n <g key={row.item.id} data-testid=\"gantt-bar\">\n <title>{label} — milestone</title>\n <rect\n x={cx - r}\n y={y - r}\n width={DIAMOND_SIZE}\n height={DIAMOND_SIZE}\n transform={`rotate(45 ${cx} ${y})`}\n className={`${visual.fill} ${critical ? 'stroke-primary' : 'stroke-input'}`}\n strokeWidth={critical ? 2 : 1}\n />\n </g>\n );\n }\n const x1 = layout.x(row.startDay);\n const x2 = layout.x(row.dueDay) + ZOOM_PX_PER_DAY[zoom];\n const width = Math.max(ZOOM_PX_PER_DAY[zoom], x2 - x1);\n return (\n <g key={row.item.id} data-testid=\"gantt-bar\">\n <title>{label}</title>\n <rect\n x={x1}\n y={y - BAR_HEIGHT / 2}\n width={width}\n height={BAR_HEIGHT}\n rx={4}\n className={`${visual.fill} ${critical ? 'stroke-primary' : 'stroke-input'}`}\n strokeWidth={critical ? 2 : 1}\n />\n </g>\n );\n })}\n </svg>\n </div>\n </div>\n\n <div className=\"flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-input px-3 py-2 text-[11px] text-muted-foreground\">\n {presentStatuses.map((status) => (\n <span key={status} className=\"inline-flex items-center gap-1.5\">\n <span className={`h-2.5 w-2.5 rounded-full ${statusVisual(status).swatch}`} />\n {enumLabel(status)}\n </span>\n ))}\n <span className=\"inline-flex items-center gap-1.5\">\n <span className=\"h-2.5 w-2.5 rotate-45 border-2 border-muted-foreground bg-background\" />\n Milestone\n </span>\n {criticalIds && criticalIds.size > 0 && (\n <span className=\"inline-flex items-center gap-1.5\">\n <span className=\"h-2.5 w-2.5 rounded-full border-2 border-primary bg-background\" />\n Critical path\n </span>\n )}\n <span className=\"inline-flex items-center gap-1.5\">\n <span className=\"h-2.5 w-0.5 bg-status-info-bg\" />\n Today\n </span>\n </div>\n\n {layout.unscheduled.length > 0 && (\n <div\n data-testid=\"gantt-unscheduled\"\n className=\"border-t border-input px-3 py-2 text-xs text-muted-foreground\"\n >\n <p className=\"font-medium text-foreground\">\n {layout.unscheduled.length} item{layout.unscheduled.length === 1 ? '' : 's'} without a\n due date {layout.unscheduled.length === 1 ? \"isn't\" : \"aren't\"} shown above:\n </p>\n <div className=\"mt-1 flex flex-wrap gap-x-3 gap-y-1\">\n {layout.unscheduled.map((item) => (\n <button\n key={item.id}\n type=\"button\"\n onClick={() => onActivateRow(item.id)}\n className=\"hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary\"\n >\n {item.key}\n </button>\n ))}\n </div>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":";;;;;;AA+CA,IAAM,IAAa,OACb,IAAkB,KAClB,IAAa,IAEb,IAAW,IADD,GAEV,IAAc,IACd,IAAa,IACb,IAAe,IACf,IAAc,IACd,IAAkB,GAElB,IAA2D;CAAE,MAAM;CAAI,OAAO;CAAG;AAEvF,SAAS,EAAa,GAAiB;AACrC,QAAO,KAAK,MAAM,KAAK,IAAI,EAAE,gBAAgB,EAAE,EAAE,aAAa,EAAE,EAAE,YAAY,CAAC,GAAG,EAAW;;AAG/F,SAAS,EAAiB,GAAqB;AAC7C,QAAO,EAAa,IAAI,KAAK,EAAI,CAAC;;AAGpC,SAAS,EAAgB,GAAwB;AAE/C,QADU,IAAI,KAAK,IAAS,EAAW,CAC9B,mBAAmB,KAAA,GAAW;EAAE,OAAO;EAAS,KAAK;EAAW,UAAU;EAAO,CAAC;;AAG7F,SAAS,EAAY,GAAwB;AAE3C,QADU,IAAI,KAAK,IAAS,EAAW,CAC9B,mBAAmB,KAAA,GAAW;EAAE,OAAO;EAAS,MAAM;EAAW,UAAU;EAAO,CAAC;;AAkB9F,SAAS,EAAa,GAA0C;AAC9D,KAAI,CAAC,EAAK,QAAS,QAAO;CAC1B,IAAM,IAAS,EAAiB,EAAK,QAAQ;AAC7C,KAAI,EAAK,mBAAmB,EAAK,kBAAkB,GAAG;EAEpD,IAAM,IAAW,KADI,KAAK,IAAI,GAAG,KAAK,KAAK,EAAK,kBAAmB,KAAS,CAAC,GACnC;AAC1C,SAAO;GAAE,MAAM;GAAO;GAAM;GAAU;GAAQ,SAAS;GAAU;;AAEnE,QAAO;EAAE,MAAM;EAAa;EAAM;EAAQ,SAAS;EAAQ;;AAS7D,SAAS,EAAU,GAAmB,GAA8B;AAElE,QADI,EAAI,SAAS,cAAoB,EAAI,SAAS,KAC3C,MAAQ,UAAU,EAAI,WAAW,EAAI,SAAS;;AAGvD,SAAgB,EAAW,EACzB,UACA,gBACA,oBACA,SACA,kBACA,YACkB;CAClB,IAAM,IAAS,QAAc;EAC3B,IAAM,IAA4B,EAAE,EAC9B,IAA+B,EAAE;AACvC,OAAK,IAAM,KAAQ,GAAO;GACxB,IAAM,IAAM,EAAa,EAAK;AAC9B,GAAI,IAAK,EAAU,KAAK,EAAI,GACvB,EAAY,KAAK,EAAK;;AAE7B,IAAU,MAAM,GAAG,MAAM,EAAE,UAAU,EAAE,WAAW,EAAE,KAAK,IAAI,cAAc,EAAE,KAAK,IAAI,CAAC;EAEvF,IAAM,IAAc,IAAI,IAAI,EAAU,KAAK,GAAG,MAAM,CAAC,EAAE,KAAK,IAAI;GAAE,KAAK;GAAG,OAAO;GAAG,CAAC,CAAC,CAAC,EACjF,IAAW,EAAa,qBAAS,IAAI,MAAM,CAAC,EAE5C,IAAO,EAAU,SAAS,MAAO,EAAE,SAAS,QAAQ,CAAC,EAAE,UAAU,EAAE,OAAO,GAAG,CAAC,EAAE,OAAO,CAAE;AAC/F,IAAK,KAAK,EAAS;EACnB,IAAM,IAAc,KAAK,IAAI,GAAG,EAAK,GAAG,GAClC,IAAY,KAAK,IAAI,GAAG,EAAK,GAAG,GAChC,IAAW,EAAgB,IAC3B,KAAK,MAAgB,KAAe,IAAM,KAAe,GAEzD,IAAY,IAAc,KAAK,IAAY,IAAc,KAAK,GAC9D,IAAa,IAAc,KAAK,IAAI,GAAG,EAAU,OAAO,GAAG,IAAW,GAEtE,IAA0C,EAAE;AAClD,OAAK,IAAI,IAAI,GAAa,KAAK,GAAW,KAAK;GAC7C,IAAM,IAAO,IAAI,KAAK,IAAI,EAAW;AAGrC,GAFI,MAAS,UAAU,EAAK,WAAW,KAAK,KAC1C,EAAM,KAAK;IAAE,KAAK;IAAG,OAAO,EAAgB,EAAE;IAAE,CAAC,EAC/C,MAAS,WAAW,EAAK,YAAY,KAAK,KAC5C,EAAM,KAAK;IAAE,KAAK;IAAG,OAAO,EAAY,EAAE;IAAE,CAAC;;EAGjD,IAAM,IAAS,EAAU,SAAS,EAAE,MAAM,QAAsB;GAC9D,IAAM,IAAO,EAAY,IAAI,EAAgB,GAAG;AAEhD,UADK,IACE,EAAgB,aAAa,SAAS,MAAQ;IACnD,IAAM,IAAK,EAAY,IAAI,EAAI,YAAY;AAC3C,QAAI,CAAC,EAAI,QAAO,EAAE;IAClB,IAAM,IACJ,EAAI,SAAS,oBAAoB,EAAI,SAAS,oBAAoB,UAAU,OACxE,IACJ,EAAI,SAAS,sBAAsB,EAAI,SAAS,oBAAoB,QAAQ;AAC9E,WAAO,CACL;KACE,IAAI,EAAI;KACR,MAAM,EAAI;KACV,YAAY,EAAI;KAChB,IAAI,EAAE,EAAU,EAAK,KAAK,EAAQ,CAAC;KACnC,IAAI,IAAc,EAAK,QAAQ,IAAW,IAAa;KACvD,IAAI,EAAE,EAAU,EAAG,KAAK,EAAM,CAAC;KAC/B,IAAI,IAAc,EAAG,QAAQ,IAAW,IAAa;KACtD,CACF;KACD,GAnBgB,EAAE;IAoBpB;AAEF,SAAO;GACL;GACA;GACA,QAAQ,EAAE,EAAS;GACnB;GACA;GACA;GACA;GACA;GACD;IACA;EAAC;EAAO;EAAM;EAAM,CAAC,EAElB,IAAkB,QAChB,EAAoB,QAAQ,MAAM,EAAM,MAAM,MAAM,EAAE,WAAW,EAAE,CAAC,EAC1E,CAAC,EAAM,CACR;AAED,QACE,kBAAC,OAAD;EAAK,WAAU;EAA+C,eAAY;YAA1E;GACE,kBAAC,KAAD;IAAG,WAAU;cAAb;KAAuB;KACA,EAAO,UAAU;KAAO;KAC5C,EAAO,UAAU,WAAW,IAAI,KAAK;KACrC,EAAO,YAAY,SAAS,IACzB,QAAQ,EAAO,YAAY,OAAO,mBAAmB,EAAO,YAAY,WAAW,IAAI,KAAK,IAAI,2BAChG;KAAG;KAEL;;GACJ,kBAAC,OAAD;IAAK,WAAU;cAAf,CACE,kBAAC,OAAD;KAAK,OAAO,EAAE,OAAO,GAAiB;KAAE,WAAU;eAAlD;MACE,kBAAC,OAAD;OAAK,OAAO,EAAE,QAAQ,GAAa;OAAE,WAAU;OAA0B,CAAA;MACxE,EAAO,UAAU,KAAK,MAAQ;OAC7B,IAAM,IAAW,GAAa,IAAI,EAAI,KAAK,GAAG,IAAI;AAClD,cACE,kBAAC,OAAD;QAEE,eAAY;QACZ,OAAO,EAAE,QAAQ,GAAU;QAC3B,WAAU;kBAEV,kBAAC,UAAD;SACE,MAAK;SACL,eAAe,EAAc,EAAI,KAAK,GAAG;SACzC,WAAW,kIAAkI,IAAW,+BAA+B;SACvL,OAAO,GAAG,EAAI,KAAK,IAAI,GAAG,EAAI,KAAK;mBAJrC;UAMG,EAAI,KAAK;UAAI;UAAC,kBAAC,QAAD;WAAM,WAAU;qBAAe,EAAS,EAAI,KAAK,OAAO,GAAG;WAAQ,CAAA;UAC3E;;QACL,EAbC,EAAI,KAAK,GAaV;QAER;MACD,EAAO,UAAU,WAAW,KAC3B,kBAAC,KAAD;OAAG,WAAU;iBAA0C;OAA4B,CAAA;MAEjF;QAEN,kBAAC,OAAD;KAAK,WAAU;eACb,kBAAC,OAAD;MACE,cAAW;MACX,OAAO,EAAO;MACd,QAAQ,EAAO;MACf,SAAS,OAAO,EAAO,UAAU,GAAG,EAAO;MAC3C,WAAU;gBALZ;OAOE,kBAAC,QAAD,EAAA,UAAA,CACE,kBAAC,UAAD;QACE,IAAG;QACH,SAAQ;QACR,MAAK;QACL,MAAK;QACL,aAAY;QACZ,cAAa;QACb,QAAO;QACP,WAAU;kBAEV,kBAAC,QAAD;SAAM,GAAE;SAAqB,MAAK;SAAiB,CAAA;QAC5C,CAAA,EACT,kBAAC,UAAD;QACE,IAAG;QACH,SAAQ;QACR,MAAK;QACL,MAAK;QACL,aAAY;QACZ,cAAa;QACb,QAAO;QACP,WAAU;kBAEV,kBAAC,QAAD;SAAM,GAAE;SAAqB,MAAK;SAAiB,CAAA;QAC5C,CAAA,CACJ,EAAA,CAAA;OAEN,EAAO,MAAM,KAAK,MACjB,kBAAC,KAAD,EAAA,UAAA,CACE,kBAAC,QAAD;QACE,IAAI,EAAO,EAAE,EAAK,IAAI;QACtB,IAAI,EAAO,EAAE,EAAK,IAAI;QACtB,IAAI;QACJ,IAAI,EAAO;QACX,WAAU;QACV,QAAO;QACP,aAAa;QACb,CAAA,EACF,kBAAC,QAAD;QACE,GAAG,EAAO,EAAE,EAAK,IAAI,GAAG;QACxB,GAAG,IAAc;QACjB,WAAU;kBAET,EAAK;QACD,CAAA,CACL,EAAA,EAjBI,EAAK,IAiBT,CACJ;OAEF,kBAAC,QAAD;QACE,IAAI,EAAO;QACX,IAAI,EAAO;QACX,IAAI;QACJ,IAAI,EAAO;QACX,WAAU;QACV,QAAO;QACP,aAAa;QACb,iBAAgB;QAChB,CAAA;OACF,kBAAC,QAAD;QACE,GAAG,EAAO,SAAS;QACnB,GAAG;QACH,WAAU;kBACX;QAEM,CAAA;OAEN,EAAO,OAAO,KAAK,MAAU;QAC5B,IAAM,KAAQ,EAAM,KAAK,EAAM,MAAM,GAC/B,IAAW,GAAiB,IAAI,EAAM,GAAG,IAAI;AACnD,eACE,kBAAC,QAAD;SAEE,eAAY;SACZ,GAAG,KAAK,EAAM,GAAG,GAAG,EAAM,GAAG,KAAK,EAAK,GAAG,EAAM,GAAG,GAAG,EAAK,GAAG,EAAM,GAAG,GAAG,EAAM,GAAG,GAAG,EAAM;SAC5F,MAAK;SACL,QAAO;SACP,aAAa,IAAW,IAAI;SAC5B,WAAW,IAAW,iBAAiB;SACvC,WAAW,QAAQ,IAAW,yBAAyB,cAAc;mBAErE,kBAAC,SAAD,EAAA,UAAA,CACG,EAAoB,EAAM,KAAK,EAC/B,EAAS,EAAM,WAAW,GAAG,KAAK,EAAS,EAAM,WAAW,CAAC,KAAK,GAC7D,EAAA,CAAA;SACH,EAbA,EAAM,GAaN;SAET;OAED,EAAO,UAAU,KAAK,GAAK,MAAU;QACpC,IAAM,IAAI,IAAc,IAAQ,IAAW,IAAa,GAClD,IAAW,GAAa,IAAI,EAAI,KAAK,GAAG,IAAI,IAC5C,IAAS,EAAa,EAAI,KAAK,OAAO,EACtC,IAAQ,GAAG,EAAI,KAAK,IAAI,KAAK,EAAI,KAAK,MAAM,IAAI,EAAU,EAAI,KAAK,OAAO,CAAC,GAAG,IAAW,uBAAuB;AACtH,YAAI,EAAI,SAAS,aAAa;SAC5B,IAAM,IAAK,EAAO,EAAE,EAAI,OAAO,GAAG,EAAgB,KAAQ,GACpD,IAAI,IAAe;AACzB,gBACE,kBAAC,KAAD;UAAqB,eAAY;oBAAjC,CACE,kBAAC,SAAD,EAAA,UAAA,CAAQ,GAAM,eAAoB,EAAA,CAAA,EAClC,kBAAC,QAAD;WACE,GAAG,IAAK;WACR,GAAG,IAAI;WACP,OAAO;WACP,QAAQ;WACR,WAAW,aAAa,EAAG,GAAG,EAAE;WAChC,WAAW,GAAG,EAAO,KAAK,GAAG,IAAW,mBAAmB;WAC3D,aAAa,IAAW,IAAI;WAC5B,CAAA,CACA;YAXI,EAAI,KAAK,GAWb;;QAGR,IAAM,IAAK,EAAO,EAAE,EAAI,SAAS,EAC3B,IAAK,EAAO,EAAE,EAAI,OAAO,GAAG,EAAgB,IAC5C,IAAQ,KAAK,IAAI,EAAgB,IAAO,IAAK,EAAG;AACtD,eACE,kBAAC,KAAD;SAAqB,eAAY;mBAAjC,CACE,kBAAC,SAAD,EAAA,UAAQ,GAAc,CAAA,EACtB,kBAAC,QAAD;UACE,GAAG;UACH,GAAG,IAAI,IAAa;UACb;UACP,QAAQ;UACR,IAAI;UACJ,WAAW,GAAG,EAAO,KAAK,GAAG,IAAW,mBAAmB;UAC3D,aAAa,IAAW,IAAI;UAC5B,CAAA,CACA;WAXI,EAAI,KAAK,GAWb;SAEN;OACE;;KACF,CAAA,CACF;;GAEN,kBAAC,OAAD;IAAK,WAAU;cAAf;KACG,EAAgB,KAAK,MACpB,kBAAC,QAAD;MAAmB,WAAU;gBAA7B,CACE,kBAAC,QAAD,EAAM,WAAW,4BAA4B,EAAa,EAAO,CAAC,UAAY,CAAA,EAC7E,EAAU,EAAO,CACb;QAHI,EAGJ,CACP;KACF,kBAAC,QAAD;MAAM,WAAU;gBAAhB,CACE,kBAAC,QAAD,EAAM,WAAU,wEAAyE,CAAA,EAAA,YAEpF;;KACN,KAAe,EAAY,OAAO,KACjC,kBAAC,QAAD;MAAM,WAAU;gBAAhB,CACE,kBAAC,QAAD,EAAM,WAAU,kEAAmE,CAAA,EAAA,gBAE9E;;KAET,kBAAC,QAAD;MAAM,WAAU;gBAAhB,CACE,kBAAC,QAAD,EAAM,WAAU,iCAAkC,CAAA,EAAA,QAE7C;;KACH;;GAEL,EAAO,YAAY,SAAS,KAC3B,kBAAC,OAAD;IACE,eAAY;IACZ,WAAU;cAFZ,CAIE,kBAAC,KAAD;KAAG,WAAU;eAAb;MACG,EAAO,YAAY;MAAO;MAAM,EAAO,YAAY,WAAW,IAAI,KAAK;MAAI;MAClE,EAAO,YAAY,WAAW,IAAI,UAAU;MAAS;MAC7D;QACJ,kBAAC,OAAD;KAAK,WAAU;eACZ,EAAO,YAAY,KAAK,MACvB,kBAAC,UAAD;MAEE,MAAK;MACL,eAAe,EAAc,EAAK,GAAG;MACrC,WAAU;gBAET,EAAK;MACC,EANF,EAAK,GAMH,CACT;KACE,CAAA,CACF;;GAEJ"}
1
+ {"version":3,"file":"GanttChart.js","names":[],"sources":["../../../src/planmagnet/dependency/GanttChart.tsx"],"sourcesContent":["import { useMemo } from 'react';\nimport { enumLabel } from '../controls/WorkItemControls';\nimport { statusVisual, STATUS_LEGEND_ORDER } from '../utils/statusColor';\nimport { dependencyTypeLabel, lagLabel, truncate } from './DependencyGraph';\n\n/**\n * Gantt timeline (ANR-24) for one project's work items. Plain SVG (recharts has no Gantt/timeline mark) — see\n * ProjectsGanttPagePage.tsx for data fetching, project choice, and the week/month zoom control this renders to.\n *\n * SCHEDULE SOURCE: `WorkItem` has no persisted start date in the current schema (`graphql/wspace.graphql` —\n * only `dueDate` and `estimateMinutes`). A bar's start is therefore DERIVED, backward from the due date, as\n * `dueDate - estimateMinutes`: an item with a due date AND a positive estimate draws as a bar; an item with a\n * due date but no (or a zero/negative) estimate draws as a milestone diamond, since there is no span to show; an\n * item with no due date at all has no anchor to place it on the axis and is counted in the unscheduled note\n * instead. This is called out again in the delivery notes — it is a real deviation from a literal \"has its own\n * start date\" reading, forced by the schema as it stands today.\n */\n\nexport interface GanttDependency {\n id: string;\n successorId: string;\n type: string;\n lagMinutes: number | null;\n}\n\nexport interface GanttWorkItem {\n id: string;\n key: string;\n title: string;\n status: string;\n dueDate: string | null;\n estimateMinutes: number | null;\n dependencies: GanttDependency[];\n}\n\nexport interface GanttChartProps {\n items: GanttWorkItem[];\n /** Work item ids on the critical path — their bars/diamonds are emphasised. */\n criticalIds?: ReadonlySet<string>;\n /** WorkItemDependency ids connecting two CONSECUTIVE critical-path items — their arrows are emphasised. */\n criticalEdgeIds?: ReadonlySet<string>;\n zoom: 'week' | 'month';\n onActivateRow: (id: string) => void;\n /** Injectable for predictable output; defaults to the real current time. */\n today?: Date;\n}\n\nconst MS_PER_DAY = 86_400_000;\nconst LABEL_COL_WIDTH = 224;\nconst ROW_HEIGHT = 28;\nconst ROW_GAP = 8;\nconst ROW_STEP = ROW_HEIGHT + ROW_GAP;\nconst AXIS_HEIGHT = 28;\nconst BAR_HEIGHT = 18;\nconst DIAMOND_SIZE = 12;\nconst SVG_PADDING = 12;\nconst DOMAIN_PAD_DAYS = 3;\n\nconst ZOOM_PX_PER_DAY: Record<GanttChartProps['zoom'], number> = { week: 28, month: 6 };\n\nfunction utcDayNumber(d: Date): number {\n return Math.floor(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) / MS_PER_DAY);\n}\n\nfunction dayNumberFromIso(iso: string): number {\n return utcDayNumber(new Date(iso));\n}\n\nfunction formatShortDate(dayNum: number): string {\n const d = new Date(dayNum * MS_PER_DAY);\n return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' });\n}\n\nfunction formatMonth(dayNum: number): string {\n const d = new Date(dayNum * MS_PER_DAY);\n return d.toLocaleDateString(undefined, { month: 'short', year: 'numeric', timeZone: 'UTC' });\n}\n\ntype ScheduledRow =\n | {\n kind: 'bar';\n item: GanttWorkItem;\n startDay: number;\n dueDay: number;\n sortDay: number;\n }\n | {\n kind: 'milestone';\n item: GanttWorkItem;\n dueDay: number;\n sortDay: number;\n };\n\nfunction scheduleItem(item: GanttWorkItem): ScheduledRow | null {\n if (!item.dueDate) return null;\n const dueDay = dayNumberFromIso(item.dueDate);\n if (item.estimateMinutes && item.estimateMinutes > 0) {\n const estimateDays = Math.max(1, Math.ceil(item.estimateMinutes / (60 * 24)));\n const startDay = dueDay - (estimateDays - 1);\n return { kind: 'bar', item, startDay, dueDay, sortDay: startDay };\n }\n return { kind: 'milestone', item, dueDay, sortDay: dueDay };\n}\n\n/**\n * Anchor x (in fractional day units, fed straight into `x()`) for one end of a row's mark. Must match the\n * geometry the row actually renders at: a bar spans a full day-width per day, so its true right edge sits one\n * day past `dueDay` (see `x2` at the bar render site below), and a milestone diamond is centered half a\n * day-width past `dueDay` (see `cx` at the milestone render site below) — not at `dueDay` itself either end.\n */\nfunction anchorDay(row: ScheduledRow, end: 'start' | 'end'): number {\n if (row.kind === 'milestone') return row.dueDay + 0.5;\n return end === 'start' ? row.startDay : row.dueDay + 1;\n}\n\nexport function GanttChart({\n items,\n criticalIds,\n criticalEdgeIds,\n zoom,\n onActivateRow,\n today,\n}: GanttChartProps) {\n const layout = useMemo(() => {\n const scheduled: ScheduledRow[] = [];\n const unscheduled: GanttWorkItem[] = [];\n for (const item of items) {\n const row = scheduleItem(item);\n if (row) scheduled.push(row);\n else unscheduled.push(item);\n }\n scheduled.sort((a, b) => a.sortDay - b.sortDay || a.item.key.localeCompare(b.item.key));\n\n const rowByItemId = new Map(scheduled.map((r, i) => [r.item.id, { row: r, index: i }]));\n const todayDay = utcDayNumber(today ?? new Date());\n\n const days = scheduled.flatMap((r) => (r.kind === 'bar' ? [r.startDay, r.dueDay] : [r.dueDay]));\n days.push(todayDay);\n const domainStart = Math.min(...days) - DOMAIN_PAD_DAYS;\n const domainEnd = Math.max(...days) + DOMAIN_PAD_DAYS;\n const pxPerDay = ZOOM_PX_PER_DAY[zoom];\n const x = (day: number) => SVG_PADDING + (day - domainStart) * pxPerDay;\n\n const plotWidth = SVG_PADDING * 2 + (domainEnd - domainStart + 1) * pxPerDay;\n const plotHeight = AXIS_HEIGHT + Math.max(1, scheduled.length) * ROW_STEP + SVG_PADDING;\n\n const ticks: { day: number; label: string }[] = [];\n for (let d = domainStart; d <= domainEnd; d++) {\n const date = new Date(d * MS_PER_DAY);\n if (zoom === 'week' && date.getUTCDay() === 1)\n ticks.push({ day: d, label: formatShortDate(d) });\n if (zoom === 'month' && date.getUTCDate() === 1)\n ticks.push({ day: d, label: formatMonth(d) });\n }\n\n const arrows = scheduled.flatMap(({ item: predecessorItem }) => {\n const from = rowByItemId.get(predecessorItem.id);\n if (!from) return [];\n return predecessorItem.dependencies.flatMap((dep) => {\n const to = rowByItemId.get(dep.successorId);\n if (!to) return [];\n const fromEnd =\n dep.type === 'START_TO_START' || dep.type === 'START_TO_FINISH' ? 'start' : 'end';\n const toEnd =\n dep.type === 'FINISH_TO_FINISH' || dep.type === 'START_TO_FINISH' ? 'end' : 'start';\n return [\n {\n id: dep.id,\n type: dep.type,\n lagMinutes: dep.lagMinutes,\n x1: x(anchorDay(from.row, fromEnd)),\n y1: AXIS_HEIGHT + from.index * ROW_STEP + ROW_HEIGHT / 2,\n x2: x(anchorDay(to.row, toEnd)),\n y2: AXIS_HEIGHT + to.index * ROW_STEP + ROW_HEIGHT / 2,\n },\n ];\n });\n });\n\n return {\n scheduled,\n unscheduled,\n todayX: x(todayDay),\n ticks,\n arrows,\n plotWidth,\n plotHeight,\n x,\n };\n }, [items, zoom, today]);\n\n const presentStatuses = useMemo(\n () => STATUS_LEGEND_ORDER.filter((s) => items.some((i) => i.status === s)),\n [items]\n );\n\n return (\n <div className=\"rounded-md border border-input bg-background\" data-testid=\"gantt-chart\">\n <p className=\"sr-only\">\n Gantt timeline with {layout.scheduled.length} scheduled work item\n {layout.scheduled.length === 1 ? '' : 's'}\n {layout.unscheduled.length > 0\n ? ` and ${layout.unscheduled.length} unscheduled item${layout.unscheduled.length === 1 ? '' : 's'} listed below the chart`\n : ''}\n . Row labels open the task detail page.\n </p>\n <div className=\"flex\">\n <div style={{ width: LABEL_COL_WIDTH }} className=\"shrink-0 border-r border-input\">\n <div style={{ height: AXIS_HEIGHT }} className=\"border-b border-input\" />\n {layout.scheduled.map((row) => {\n const critical = criticalIds?.has(row.item.id) ?? false;\n return (\n <div\n key={row.item.id}\n data-testid=\"gantt-row\"\n style={{ height: ROW_STEP }}\n className=\"flex items-center px-2\"\n >\n <button\n type=\"button\"\n onClick={() => onActivateRow(row.item.id)}\n className={`min-w-0 truncate text-left text-xs hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary ${critical ? 'font-semibold text-primary' : 'text-foreground'}`}\n title={`${row.item.key} ${row.item.title}`}\n >\n {row.item.key} <span className=\"font-normal\">{truncate(row.item.title, 28)}</span>\n </button>\n </div>\n );\n })}\n {layout.scheduled.length === 0 && (\n <p className=\"px-2 py-3 text-xs text-muted-foreground\">No scheduled work items.</p>\n )}\n </div>\n\n <div className=\"overflow-x-auto\">\n <svg\n aria-label=\"Project timeline\"\n width={layout.plotWidth}\n height={layout.plotHeight}\n viewBox={`0 0 ${layout.plotWidth} ${layout.plotHeight}`}\n className=\"block\"\n >\n <defs>\n <marker\n id=\"gantt-arrow\"\n viewBox=\"0 0 10 10\"\n refX=\"9\"\n refY=\"5\"\n markerWidth=\"6\"\n markerHeight=\"6\"\n orient=\"auto-start-reverse\"\n className=\"text-border-strong\"\n >\n <path d=\"M0,0 L10,5 L0,10 z\" fill=\"currentColor\" />\n </marker>\n <marker\n id=\"gantt-arrow-critical\"\n viewBox=\"0 0 10 10\"\n refX=\"9\"\n refY=\"5\"\n markerWidth=\"6\"\n markerHeight=\"6\"\n orient=\"auto-start-reverse\"\n className=\"text-primary\"\n >\n <path d=\"M0,0 L10,5 L0,10 z\" fill=\"currentColor\" />\n </marker>\n </defs>\n\n {layout.ticks.map((tick) => (\n <g key={tick.day}>\n <line\n x1={layout.x(tick.day)}\n x2={layout.x(tick.day)}\n y1={0}\n y2={layout.plotHeight}\n style={{ color: 'var(--color-chart-grid)' }}\n stroke=\"currentColor\"\n strokeWidth={1}\n />\n <text\n x={layout.x(tick.day) + 3}\n y={AXIS_HEIGHT - 10}\n className=\"text-[10px]\"\n style={{ fill: 'var(--color-chart-axis)' }}\n >\n {tick.label}\n </text>\n </g>\n ))}\n\n <line\n x1={layout.todayX}\n x2={layout.todayX}\n y1={0}\n y2={layout.plotHeight}\n className=\"text-status-info-bg\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeDasharray=\"4 3\"\n />\n <text\n x={layout.todayX + 4}\n y={12}\n className=\"fill-status-info-text text-[10px] font-medium\"\n >\n Today\n </text>\n\n {layout.arrows.map((arrow) => {\n const midX = (arrow.x1 + arrow.x2) / 2;\n const critical = criticalEdgeIds?.has(arrow.id) ?? false;\n return (\n <path\n key={arrow.id}\n data-testid=\"gantt-arrow\"\n d={`M ${arrow.x1},${arrow.y1} C ${midX},${arrow.y1} ${midX},${arrow.y2} ${arrow.x2},${arrow.y2}`}\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={critical ? 2 : 1.25}\n className={critical ? 'text-primary' : 'text-border-strong'}\n markerEnd={`url(#${critical ? 'gantt-arrow-critical' : 'gantt-arrow'})`}\n >\n <title>\n {dependencyTypeLabel(arrow.type)}\n {lagLabel(arrow.lagMinutes) ? ` (${lagLabel(arrow.lagMinutes)})` : ''}\n </title>\n </path>\n );\n })}\n\n {layout.scheduled.map((row, index) => {\n const y = AXIS_HEIGHT + index * ROW_STEP + ROW_HEIGHT / 2;\n const critical = criticalIds?.has(row.item.id) ?? false;\n const visual = statusVisual(row.item.status);\n const label = `${row.item.key} — ${row.item.title} (${enumLabel(row.item.status)})${critical ? ', on critical path' : ''}`;\n if (row.kind === 'milestone') {\n const cx = layout.x(row.dueDay) + ZOOM_PX_PER_DAY[zoom] / 2;\n const r = DIAMOND_SIZE / 2;\n return (\n <g key={row.item.id} data-testid=\"gantt-bar\">\n <title>{label} — milestone</title>\n <rect\n x={cx - r}\n y={y - r}\n width={DIAMOND_SIZE}\n height={DIAMOND_SIZE}\n transform={`rotate(45 ${cx} ${y})`}\n className={critical ? 'stroke-primary' : 'stroke-input'}\n style={{ fill: visual.color }}\n strokeWidth={critical ? 2 : 1}\n />\n </g>\n );\n }\n const x1 = layout.x(row.startDay);\n const x2 = layout.x(row.dueDay) + ZOOM_PX_PER_DAY[zoom];\n const width = Math.max(ZOOM_PX_PER_DAY[zoom], x2 - x1);\n return (\n <g key={row.item.id} data-testid=\"gantt-bar\">\n <title>{label}</title>\n <rect\n x={x1}\n y={y - BAR_HEIGHT / 2}\n width={width}\n height={BAR_HEIGHT}\n rx={4}\n className={critical ? 'stroke-primary' : 'stroke-input'}\n style={{ fill: visual.color }}\n strokeWidth={critical ? 2 : 1}\n />\n </g>\n );\n })}\n </svg>\n </div>\n </div>\n\n <div className=\"flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-input px-3 py-2 text-[11px] text-muted-foreground\">\n {presentStatuses.map((status) => (\n <span key={status} className=\"inline-flex items-center gap-1.5\">\n <span\n className=\"h-2.5 w-2.5 rounded-full\"\n style={{ backgroundColor: statusVisual(status).color }}\n />\n {enumLabel(status)}\n </span>\n ))}\n <span className=\"inline-flex items-center gap-1.5\">\n <span className=\"h-2.5 w-2.5 rotate-45 border-2 border-muted-foreground bg-background\" />\n Milestone\n </span>\n {criticalIds && criticalIds.size > 0 && (\n <span className=\"inline-flex items-center gap-1.5\">\n <span className=\"h-2.5 w-2.5 rounded-full border-2 border-primary bg-background\" />\n Critical path\n </span>\n )}\n <span className=\"inline-flex items-center gap-1.5\">\n <span className=\"h-2.5 w-0.5 bg-status-info-bg\" />\n Today\n </span>\n </div>\n\n {layout.unscheduled.length > 0 && (\n <div\n data-testid=\"gantt-unscheduled\"\n className=\"border-t border-input px-3 py-2 text-xs text-muted-foreground\"\n >\n <p className=\"font-medium text-foreground\">\n {layout.unscheduled.length} item{layout.unscheduled.length === 1 ? '' : 's'} without a\n due date {layout.unscheduled.length === 1 ? \"isn't\" : \"aren't\"} shown above:\n </p>\n <div className=\"mt-1 flex flex-wrap gap-x-3 gap-y-1\">\n {layout.unscheduled.map((item) => (\n <button\n key={item.id}\n type=\"button\"\n onClick={() => onActivateRow(item.id)}\n className=\"hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary\"\n >\n {item.key}\n </button>\n ))}\n </div>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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"}
@@ -68,7 +68,10 @@ function C({ x: e, y: t, width: n, height: r, payload: i }) {
68
68
  width: n,
69
69
  height: r,
70
70
  rx: 2,
71
- className: `${o.fill} ${o.stroke}`,
71
+ style: {
72
+ fill: o.color,
73
+ stroke: o.color
74
+ },
72
75
  strokeWidth: 1
73
76
  }), /* @__PURE__ */ d("text", {
74
77
  x: s ? e - 6 : e + n + 6,
@@ -83,7 +86,7 @@ function w({ sourceX: e, sourceY: t, targetX: n, targetY: r, sourceControlX: i,
83
86
  let l = a(c.source.status);
84
87
  return /* @__PURE__ */ d("path", {
85
88
  d: `M${e},${t} C${i},${t} ${o},${r} ${n},${r}`,
86
- className: l.stroke,
89
+ style: { stroke: l.color },
87
90
  strokeWidth: Math.max(s, 1),
88
91
  strokeOpacity: .45,
89
92
  fill: "none"
@@ -1 +1 @@
1
- {"version":3,"file":"StatusFlowCard.js","names":[],"sources":["../../../../src/planmagnet/pages/analytics/StatusFlowCard.tsx"],"sourcesContent":["import { useMemo } from 'react';\nimport { gql } from '@apollo/client';\nimport { useQuery } from '@apollo/client/react';\nimport { Card, CardContent, CardHeader, CardTitle } from '@burdenoff/fe-libs/ui';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { Workflow } from 'lucide-react';\nimport { ResponsiveContainer, Sankey, Tooltip } from 'recharts';\nimport { WORK_ITEM_STATUSES, enumLabel } from '../../controls/WorkItemControls';\nimport { statusVisual } from '../../utils/statusColor';\nimport { isForbidden } from '../../utils/graphqlErrors';\n\n/**\n * ANR-24: status-flow Sankey (wspace-planmagnet-svc#65 `planmagnetStatusFlow`) — aggregated status-to-status\n * transition counts for one project, over a date window capped server-side at 366 days.\n *\n * recharts' Sankey lays its nodes out by depth, walked outward from every node with no incoming link\n * (`getNodesTree` in recharts' own source), with NO cycle guard — a real transition graph almost always has\n * cycles (e.g. IN_REVIEW -> IN_PROGRESS -> IN_REVIEW, a change request), which would recurse forever. The fix:\n * every status becomes TWO Sankey nodes, a \"from\" one and a \"to\" one, so every link only ever points from the\n * \"from\" column to the \"to\" column — a strict two-level DAG by construction, however tangled the underlying\n * status graph is. This is a display transform only; it does not change what the data means (every link is\n * still exactly one observed fromStatus -> toStatus edge, whichever status is on which side of the column).\n */\n\ntype WorkItemStatusValue = (typeof WORK_ITEM_STATUSES)[number];\n\nconst GET_STATUS_FLOW = gql`\n query GetAnalyticsStatusFlow(\n $workspaceId: ID!\n $projectId: ID!\n $from: DateTime!\n $to: DateTime!\n ) {\n planmagnetStatusFlow(workspaceId: $workspaceId, projectId: $projectId, from: $from, to: $to) {\n fromStatus\n toStatus\n count\n }\n }\n`;\n\ninterface StatusFlowEdge {\n fromStatus: WorkItemStatusValue;\n toStatus: WorkItemStatusValue;\n count: number;\n}\n\ninterface StatusFlowData {\n planmagnetStatusFlow: StatusFlowEdge[];\n}\n\ninterface FlowNodeDatum {\n name: string;\n status: WorkItemStatusValue;\n side: 'from' | 'to';\n}\n\ninterface FlowLinkDatum {\n source: number;\n target: number;\n value: number;\n}\n\ninterface FlowLinkPayload {\n source: FlowNodeDatum;\n target: FlowNodeDatum;\n value: number;\n}\n\n/** The server's own cap (see the field's doc comment in graphql/wspace.graphql) — the UI never sends a wider\n * window, and says so when the page's own range would have asked for one. */\nconst MAX_WINDOW_DAYS = 366;\nconst MAX_WINDOW_MS = MAX_WINDOW_DAYS * 86_400_000;\n\nfunction clampWindow(\n startDate: string,\n endDate: string\n): { from: string; to: string; clamped: boolean } {\n const startMs = Date.parse(startDate);\n const endMs = Date.parse(endDate);\n if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs - startMs <= MAX_WINDOW_MS) {\n return { from: startDate, to: endDate, clamped: false };\n }\n return { from: new Date(endMs - MAX_WINDOW_MS).toISOString(), to: endDate, clamped: true };\n}\n\n/** Every status that actually appears becomes a \"from\" node (left column) and/or a \"to\" node (right column), in\n * the app's canonical status order — never in whatever order the API happened to return edges. */\nfunction buildFlow(edges: StatusFlowEdge[]): { nodes: FlowNodeDatum[]; links: FlowLinkDatum[] } {\n const fromStatuses = WORK_ITEM_STATUSES.filter((s) => edges.some((e) => e.fromStatus === s));\n const toStatuses = WORK_ITEM_STATUSES.filter((s) => edges.some((e) => e.toStatus === s));\n const fromIndex = new Map(fromStatuses.map((s, i) => [s, i]));\n const toIndex = new Map(toStatuses.map((s, i) => [s, fromStatuses.length + i]));\n\n const nodes: FlowNodeDatum[] = [\n ...fromStatuses.map((s) => ({ name: enumLabel(s), status: s, side: 'from' as const })),\n ...toStatuses.map((s) => ({ name: enumLabel(s), status: s, side: 'to' as const })),\n ];\n\n const links: FlowLinkDatum[] = edges.flatMap((e) => {\n const source = fromIndex.get(e.fromStatus);\n const target = toIndex.get(e.toStatus);\n // Every edge's statuses come straight from fromStatuses/toStatuses above, so this can never actually miss —\n // the check just keeps the result type honest without a non-null assertion.\n return source === undefined || target === undefined ? [] : [{ source, target, value: e.count }];\n });\n\n return { nodes, links };\n}\n\ninterface FlowNodeProps {\n x: number;\n y: number;\n width: number;\n height: number;\n payload: FlowNodeDatum;\n}\n\nfunction FlowNode({ x, y, width, height, payload }: FlowNodeProps) {\n const visual = statusVisual(payload.status);\n const isFrom = payload.side === 'from';\n return (\n <g>\n <rect\n x={x}\n y={y}\n width={width}\n height={height}\n rx={2}\n className={`${visual.fill} ${visual.stroke}`}\n strokeWidth={1}\n />\n <text\n x={isFrom ? x - 6 : x + width + 6}\n y={y + height / 2}\n dy=\"0.32em\"\n textAnchor={isFrom ? 'end' : 'start'}\n className=\"fill-foreground text-[11px]\"\n >\n {payload.name}\n </text>\n </g>\n );\n}\n\ninterface FlowLinkProps {\n sourceX: number;\n sourceY: number;\n targetX: number;\n targetY: number;\n sourceControlX: number;\n targetControlX: number;\n linkWidth: number;\n payload: FlowLinkPayload;\n}\n\nfunction FlowLink({\n sourceX,\n sourceY,\n targetX,\n targetY,\n sourceControlX,\n targetControlX,\n linkWidth,\n payload,\n}: FlowLinkProps) {\n // Colored by the transition's origin status — the same fixed status->color mapping every other visualization\n // in this app (Gantt, dependency graph) uses, read via statusVisual rather than a second color table.\n const visual = statusVisual(payload.source.status);\n return (\n <path\n d={`M${sourceX},${sourceY} C${sourceControlX},${sourceY} ${targetControlX},${targetY} ${targetX},${targetY}`}\n className={visual.stroke}\n strokeWidth={Math.max(linkWidth, 1)}\n strokeOpacity={0.45}\n fill=\"none\"\n />\n );\n}\n\nexport function StatusFlowCard({\n workspaceId,\n projectId,\n startDate,\n endDate,\n}: {\n workspaceId?: string | null;\n /** Status flow is always one project (`projectId: ID!` on the server) — same convention as CycleTimeCard. */\n projectId: string;\n startDate: string;\n endDate: string;\n}) {\n const { t } = useI18n();\n const clamp = useMemo(() => clampWindow(startDate, endDate), [startDate, endDate]);\n const { data, loading, error } = useQuery<StatusFlowData>(GET_STATUS_FLOW, {\n variables: { workspaceId, projectId, from: clamp.from, to: clamp.to },\n skip: !workspaceId || !projectId,\n });\n // Recomputed straight from `data` on every render rather than behind its own useMemo: at most a handful of\n // statuses and edges, and `?? []` below would hand memoization a fresh array identity every render anyway —\n // same call this page's own project/sprint default-picking makes for a similarly small list.\n const edges = data?.planmagnetStatusFlow ?? [];\n const { nodes, links } = buildFlow(edges);\n const sortedEdges = [...edges].sort((a, b) => b.count - a.count);\n\n return (\n <Card data-testid=\"status-flow-card\">\n <CardHeader>\n <CardTitle className=\"flex items-center gap-2\">\n <Workflow className=\"h-4 w-4\" />\n {t('planmagnet.analytics.statusFlow.title', 'Status flow')}\n </CardTitle>\n <p className=\"text-xs text-muted-foreground\">\n {t(\n 'planmagnet.analytics.statusFlow.subtitle',\n 'How work items moved between statuses in the selected range.'\n )}\n </p>\n {clamp.clamped && (\n <p className=\"text-xs text-status-warning-text\">\n {t(\n 'planmagnet.analytics.statusFlow.clamped',\n 'Status flow is capped at a {{days}}-day window — showing the most recent {{days}} days of the selected range (from {{from}}).',\n { days: MAX_WINDOW_DAYS, from: clamp.from.slice(0, 10) }\n )}\n </p>\n )}\n </CardHeader>\n <CardContent>\n {!projectId ? (\n <p className=\"text-sm text-muted-foreground\">\n {t(\n 'planmagnet.analytics.statusFlow.noProject',\n 'Select a project to see its status flow.'\n )}\n </p>\n ) : loading && !data ? (\n <div className=\"flex justify-center py-12\">\n <div className=\"animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full\" />\n </div>\n ) : error ? (\n <p className=\"text-sm text-status-error-text\">\n {isForbidden(error)\n ? t(\n 'planmagnet.analytics.statusFlow.forbidden',\n 'You do not have access to status flow for this project.'\n )\n : `${t('planmagnet.analytics.statusFlow.error', 'Status flow could not be loaded')}: ${error.message}`}\n </p>\n ) : edges.length === 0 ? (\n <p className=\"text-sm text-muted-foreground py-8 text-center\">\n {t(\n 'planmagnet.analytics.statusFlow.empty',\n 'No status changes recorded for this project in the selected range.'\n )}\n </p>\n ) : (\n <div className=\"space-y-4\">\n <div data-testid=\"viz-status-flow\">\n <ResponsiveContainer width=\"100%\" height={Math.max(260, nodes.length * 24)}>\n <Sankey\n data={{ nodes, links }}\n node={FlowNode}\n link={FlowLink}\n nodeWidth={10}\n nodePadding={16}\n linkCurvature={0.5}\n margin={{ top: 8, right: 90, bottom: 8, left: 90 }}\n >\n <Tooltip\n content={({ active, payload }) => {\n if (!active || !payload || payload.length === 0) return null;\n // recharts types a Sankey tooltip payload's inner `.payload` as `any` — it nests our own\n // node/link datum one level below recharts' own node/link render-props wrapper. Narrowed\n // here, once, rather than threading `any` through the render below.\n const el = payload[0]?.payload as\n | { payload?: FlowNodeDatum | FlowLinkPayload }\n | undefined;\n const inner = el?.payload;\n if (!inner) return null;\n if ('source' in inner) {\n return (\n <div className=\"rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs shadow-md\">\n <p className=\"font-medium text-popover-foreground\">\n {inner.source.name} → {inner.target.name}\n </p>\n <p className=\"text-muted-foreground\">\n {t(\n 'planmagnet.analytics.statusFlow.tooltipCount',\n '{{count}} item(s)',\n {\n count: inner.value,\n }\n )}\n </p>\n </div>\n );\n }\n return (\n <div className=\"rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs shadow-md\">\n <p className=\"font-medium text-popover-foreground\">{inner.name}</p>\n </div>\n );\n }}\n />\n </Sankey>\n </ResponsiveContainer>\n </div>\n\n {/* Accessible table alternative — every edge the Sankey draws, in the same data. */}\n <table className=\"w-full text-sm\" data-testid=\"viz-status-flow-table\">\n <caption className=\"mb-2 text-left text-xs text-muted-foreground\">\n {t('planmagnet.analytics.statusFlow.tableCaption', 'Status transitions')}\n </caption>\n <thead>\n <tr className=\"text-left text-xs text-muted-foreground\">\n <th className=\"py-1 pr-2 font-normal\">\n {t('planmagnet.analytics.statusFlow.from', 'From')}\n </th>\n <th className=\"py-1 pr-2 font-normal\">\n {t('planmagnet.analytics.statusFlow.to', 'To')}\n </th>\n <th className=\"py-1 text-right font-normal\">\n {t('planmagnet.analytics.statusFlow.count', 'Count')}\n </th>\n </tr>\n </thead>\n <tbody>\n {sortedEdges.map((e) => (\n <tr key={`${e.fromStatus}-${e.toStatus}`} className=\"border-t\">\n <td className=\"py-1.5 pr-2\">{enumLabel(e.fromStatus)}</td>\n <td className=\"py-1.5 pr-2\">{enumLabel(e.toStatus)}</td>\n <td className=\"py-1.5 text-right tabular-nums\">{e.count}</td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )}\n </CardContent>\n </Card>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"StatusFlowCard.js","names":[],"sources":["../../../../src/planmagnet/pages/analytics/StatusFlowCard.tsx"],"sourcesContent":["import { useMemo } from 'react';\nimport { gql } from '@apollo/client';\nimport { useQuery } from '@apollo/client/react';\nimport { Card, CardContent, CardHeader, CardTitle } from '@burdenoff/fe-libs/ui';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { Workflow } from 'lucide-react';\nimport { ResponsiveContainer, Sankey, Tooltip } from 'recharts';\nimport { WORK_ITEM_STATUSES, enumLabel } from '../../controls/WorkItemControls';\nimport { statusVisual } from '../../utils/statusColor';\nimport { isForbidden } from '../../utils/graphqlErrors';\n\n/**\n * ANR-24: status-flow Sankey (wspace-planmagnet-svc#65 `planmagnetStatusFlow`) — aggregated status-to-status\n * transition counts for one project, over a date window capped server-side at 366 days.\n *\n * recharts' Sankey lays its nodes out by depth, walked outward from every node with no incoming link\n * (`getNodesTree` in recharts' own source), with NO cycle guard — a real transition graph almost always has\n * cycles (e.g. IN_REVIEW -> IN_PROGRESS -> IN_REVIEW, a change request), which would recurse forever. The fix:\n * every status becomes TWO Sankey nodes, a \"from\" one and a \"to\" one, so every link only ever points from the\n * \"from\" column to the \"to\" column — a strict two-level DAG by construction, however tangled the underlying\n * status graph is. This is a display transform only; it does not change what the data means (every link is\n * still exactly one observed fromStatus -> toStatus edge, whichever status is on which side of the column).\n */\n\ntype WorkItemStatusValue = (typeof WORK_ITEM_STATUSES)[number];\n\nconst GET_STATUS_FLOW = gql`\n query GetAnalyticsStatusFlow(\n $workspaceId: ID!\n $projectId: ID!\n $from: DateTime!\n $to: DateTime!\n ) {\n planmagnetStatusFlow(workspaceId: $workspaceId, projectId: $projectId, from: $from, to: $to) {\n fromStatus\n toStatus\n count\n }\n }\n`;\n\ninterface StatusFlowEdge {\n fromStatus: WorkItemStatusValue;\n toStatus: WorkItemStatusValue;\n count: number;\n}\n\ninterface StatusFlowData {\n planmagnetStatusFlow: StatusFlowEdge[];\n}\n\ninterface FlowNodeDatum {\n name: string;\n status: WorkItemStatusValue;\n side: 'from' | 'to';\n}\n\ninterface FlowLinkDatum {\n source: number;\n target: number;\n value: number;\n}\n\ninterface FlowLinkPayload {\n source: FlowNodeDatum;\n target: FlowNodeDatum;\n value: number;\n}\n\n/** The server's own cap (see the field's doc comment in graphql/wspace.graphql) — the UI never sends a wider\n * window, and says so when the page's own range would have asked for one. */\nconst MAX_WINDOW_DAYS = 366;\nconst MAX_WINDOW_MS = MAX_WINDOW_DAYS * 86_400_000;\n\nfunction clampWindow(\n startDate: string,\n endDate: string\n): { from: string; to: string; clamped: boolean } {\n const startMs = Date.parse(startDate);\n const endMs = Date.parse(endDate);\n if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs - startMs <= MAX_WINDOW_MS) {\n return { from: startDate, to: endDate, clamped: false };\n }\n return { from: new Date(endMs - MAX_WINDOW_MS).toISOString(), to: endDate, clamped: true };\n}\n\n/** Every status that actually appears becomes a \"from\" node (left column) and/or a \"to\" node (right column), in\n * the app's canonical status order — never in whatever order the API happened to return edges. */\nfunction buildFlow(edges: StatusFlowEdge[]): { nodes: FlowNodeDatum[]; links: FlowLinkDatum[] } {\n const fromStatuses = WORK_ITEM_STATUSES.filter((s) => edges.some((e) => e.fromStatus === s));\n const toStatuses = WORK_ITEM_STATUSES.filter((s) => edges.some((e) => e.toStatus === s));\n const fromIndex = new Map(fromStatuses.map((s, i) => [s, i]));\n const toIndex = new Map(toStatuses.map((s, i) => [s, fromStatuses.length + i]));\n\n const nodes: FlowNodeDatum[] = [\n ...fromStatuses.map((s) => ({ name: enumLabel(s), status: s, side: 'from' as const })),\n ...toStatuses.map((s) => ({ name: enumLabel(s), status: s, side: 'to' as const })),\n ];\n\n const links: FlowLinkDatum[] = edges.flatMap((e) => {\n const source = fromIndex.get(e.fromStatus);\n const target = toIndex.get(e.toStatus);\n // Every edge's statuses come straight from fromStatuses/toStatuses above, so this can never actually miss —\n // the check just keeps the result type honest without a non-null assertion.\n return source === undefined || target === undefined ? [] : [{ source, target, value: e.count }];\n });\n\n return { nodes, links };\n}\n\ninterface FlowNodeProps {\n x: number;\n y: number;\n width: number;\n height: number;\n payload: FlowNodeDatum;\n}\n\nfunction FlowNode({ x, y, width, height, payload }: FlowNodeProps) {\n const visual = statusVisual(payload.status);\n const isFrom = payload.side === 'from';\n return (\n <g>\n <rect\n x={x}\n y={y}\n width={width}\n height={height}\n rx={2}\n style={{ fill: visual.color, stroke: visual.color }}\n strokeWidth={1}\n />\n <text\n x={isFrom ? x - 6 : x + width + 6}\n y={y + height / 2}\n dy=\"0.32em\"\n textAnchor={isFrom ? 'end' : 'start'}\n className=\"fill-foreground text-[11px]\"\n >\n {payload.name}\n </text>\n </g>\n );\n}\n\ninterface FlowLinkProps {\n sourceX: number;\n sourceY: number;\n targetX: number;\n targetY: number;\n sourceControlX: number;\n targetControlX: number;\n linkWidth: number;\n payload: FlowLinkPayload;\n}\n\nfunction FlowLink({\n sourceX,\n sourceY,\n targetX,\n targetY,\n sourceControlX,\n targetControlX,\n linkWidth,\n payload,\n}: FlowLinkProps) {\n // Colored by the transition's origin status — the same fixed status->color mapping every other visualization\n // in this app (Gantt, dependency graph) uses, read via statusVisual rather than a second color table.\n const visual = statusVisual(payload.source.status);\n return (\n <path\n d={`M${sourceX},${sourceY} C${sourceControlX},${sourceY} ${targetControlX},${targetY} ${targetX},${targetY}`}\n style={{ stroke: visual.color }}\n strokeWidth={Math.max(linkWidth, 1)}\n strokeOpacity={0.45}\n fill=\"none\"\n />\n );\n}\n\nexport function StatusFlowCard({\n workspaceId,\n projectId,\n startDate,\n endDate,\n}: {\n workspaceId?: string | null;\n /** Status flow is always one project (`projectId: ID!` on the server) — same convention as CycleTimeCard. */\n projectId: string;\n startDate: string;\n endDate: string;\n}) {\n const { t } = useI18n();\n const clamp = useMemo(() => clampWindow(startDate, endDate), [startDate, endDate]);\n const { data, loading, error } = useQuery<StatusFlowData>(GET_STATUS_FLOW, {\n variables: { workspaceId, projectId, from: clamp.from, to: clamp.to },\n skip: !workspaceId || !projectId,\n });\n // Recomputed straight from `data` on every render rather than behind its own useMemo: at most a handful of\n // statuses and edges, and `?? []` below would hand memoization a fresh array identity every render anyway —\n // same call this page's own project/sprint default-picking makes for a similarly small list.\n const edges = data?.planmagnetStatusFlow ?? [];\n const { nodes, links } = buildFlow(edges);\n const sortedEdges = [...edges].sort((a, b) => b.count - a.count);\n\n return (\n <Card data-testid=\"status-flow-card\">\n <CardHeader>\n <CardTitle className=\"flex items-center gap-2\">\n <Workflow className=\"h-4 w-4\" />\n {t('planmagnet.analytics.statusFlow.title', 'Status flow')}\n </CardTitle>\n <p className=\"text-xs text-muted-foreground\">\n {t(\n 'planmagnet.analytics.statusFlow.subtitle',\n 'How work items moved between statuses in the selected range.'\n )}\n </p>\n {clamp.clamped && (\n <p className=\"text-xs text-status-warning-text\">\n {t(\n 'planmagnet.analytics.statusFlow.clamped',\n 'Status flow is capped at a {{days}}-day window — showing the most recent {{days}} days of the selected range (from {{from}}).',\n { days: MAX_WINDOW_DAYS, from: clamp.from.slice(0, 10) }\n )}\n </p>\n )}\n </CardHeader>\n <CardContent>\n {!projectId ? (\n <p className=\"text-sm text-muted-foreground\">\n {t(\n 'planmagnet.analytics.statusFlow.noProject',\n 'Select a project to see its status flow.'\n )}\n </p>\n ) : loading && !data ? (\n <div className=\"flex justify-center py-12\">\n <div className=\"animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full\" />\n </div>\n ) : error ? (\n <p className=\"text-sm text-status-error-text\">\n {isForbidden(error)\n ? t(\n 'planmagnet.analytics.statusFlow.forbidden',\n 'You do not have access to status flow for this project.'\n )\n : `${t('planmagnet.analytics.statusFlow.error', 'Status flow could not be loaded')}: ${error.message}`}\n </p>\n ) : edges.length === 0 ? (\n <p className=\"text-sm text-muted-foreground py-8 text-center\">\n {t(\n 'planmagnet.analytics.statusFlow.empty',\n 'No status changes recorded for this project in the selected range.'\n )}\n </p>\n ) : (\n <div className=\"space-y-4\">\n <div data-testid=\"viz-status-flow\">\n <ResponsiveContainer width=\"100%\" height={Math.max(260, nodes.length * 24)}>\n <Sankey\n data={{ nodes, links }}\n node={FlowNode}\n link={FlowLink}\n nodeWidth={10}\n nodePadding={16}\n linkCurvature={0.5}\n margin={{ top: 8, right: 90, bottom: 8, left: 90 }}\n >\n <Tooltip\n content={({ active, payload }) => {\n if (!active || !payload || payload.length === 0) return null;\n // recharts types a Sankey tooltip payload's inner `.payload` as `any` — it nests our own\n // node/link datum one level below recharts' own node/link render-props wrapper. Narrowed\n // here, once, rather than threading `any` through the render below.\n const el = payload[0]?.payload as\n | { payload?: FlowNodeDatum | FlowLinkPayload }\n | undefined;\n const inner = el?.payload;\n if (!inner) return null;\n if ('source' in inner) {\n return (\n <div className=\"rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs shadow-md\">\n <p className=\"font-medium text-popover-foreground\">\n {inner.source.name} → {inner.target.name}\n </p>\n <p className=\"text-muted-foreground\">\n {t(\n 'planmagnet.analytics.statusFlow.tooltipCount',\n '{{count}} item(s)',\n {\n count: inner.value,\n }\n )}\n </p>\n </div>\n );\n }\n return (\n <div className=\"rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs shadow-md\">\n <p className=\"font-medium text-popover-foreground\">{inner.name}</p>\n </div>\n );\n }}\n />\n </Sankey>\n </ResponsiveContainer>\n </div>\n\n {/* Accessible table alternative — every edge the Sankey draws, in the same data. */}\n <table className=\"w-full text-sm\" data-testid=\"viz-status-flow-table\">\n <caption className=\"mb-2 text-left text-xs text-muted-foreground\">\n {t('planmagnet.analytics.statusFlow.tableCaption', 'Status transitions')}\n </caption>\n <thead>\n <tr className=\"text-left text-xs text-muted-foreground\">\n <th className=\"py-1 pr-2 font-normal\">\n {t('planmagnet.analytics.statusFlow.from', 'From')}\n </th>\n <th className=\"py-1 pr-2 font-normal\">\n {t('planmagnet.analytics.statusFlow.to', 'To')}\n </th>\n <th className=\"py-1 text-right font-normal\">\n {t('planmagnet.analytics.statusFlow.count', 'Count')}\n </th>\n </tr>\n </thead>\n <tbody>\n {sortedEdges.map((e) => (\n <tr key={`${e.fromStatus}-${e.toStatus}`} className=\"border-t\">\n <td className=\"py-1.5 pr-2\">{enumLabel(e.fromStatus)}</td>\n <td className=\"py-1.5 pr-2\">{enumLabel(e.toStatus)}</td>\n <td className=\"py-1.5 text-right tabular-nums\">{e.count}</td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n )}\n </CardContent>\n </Card>\n );\n}\n"],"mappings":"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"}
@@ -142,8 +142,11 @@ function T(e) {
142
142
  y: n,
143
143
  width: r,
144
144
  height: i,
145
- className: "fill-chart-2 stroke-background",
146
- style: { fillOpacity: w(m) },
145
+ className: "stroke-background",
146
+ style: {
147
+ fill: "var(--color-chart-2)",
148
+ fillOpacity: w(m)
149
+ },
147
150
  strokeWidth: 2
148
151
  }), x = v && /* @__PURE__ */ d("text", {
149
152
  x: t + 4,
@@ -1 +1 @@
1
- {"version":3,"file":"WorkBreakdownCard.js","names":[],"sources":["../../../../src/planmagnet/pages/analytics/WorkBreakdownCard.tsx"],"sourcesContent":["import { useState } from 'react';\nimport { gql } from '@apollo/client';\nimport { useQuery } from '@apollo/client/react';\nimport { Card, CardContent, CardHeader, CardTitle } from '@burdenoff/fe-libs/ui';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { LayoutGrid } from 'lucide-react';\nimport { ResponsiveContainer, Tooltip, Treemap } from 'recharts';\nimport { usePlanMagnetNavigate } from '../../providers/PlanMagnetProvider';\nimport { rowLinkProps } from '../../utils/rowNavigation';\nimport { isForbidden } from '../../utils/graphqlErrors';\nimport { truncate } from '../../dependency/DependencyGraph';\n\n/**\n * ANR-24: work breakdown Treemap (wspace-planmagnet-svc#65 `planmagnetWorkBreakdown`) — live (not date-ranged;\n * see the field's own doc comment) open/done counts grouped project -> epic, with a \"No epic\" bucket per\n * project for items an epic rollup doesn't reach (see PlanmagnetNoEpicWorkBreakdown's doc comment: direct\n * children only, same as PlanmagnetEpicWorkBreakdown — a sub-task nested two levels under an epic falls here\n * too, not under its grandparent epic).\n *\n * recharts' Treemap computes a node's own size as the SUM of its children's sizes when `node.children` is set\n * (see `computeNode` in recharts' source) — so each project's own cell size, and its done-ratio color, both\n * come from summing this card's own visualized epic + \"No epic\" leaves, not from the server's separate\n * project-level totals (which include every live item in the project, not only the ones a leaf here\n * represents — see PlanmagnetProjectWorkBreakdown's doc comment). What the treemap draws and what the table\n * below it lists are therefore always the same numbers.\n */\n\ntype BreakdownMetric = 'items' | 'points';\n\nconst GET_WORK_BREAKDOWN = gql`\n query GetAnalyticsWorkBreakdown($workspaceId: ID!, $projectIds: [ID!]) {\n planmagnetWorkBreakdown(workspaceId: $workspaceId, projectIds: $projectIds) {\n truncatedProjects\n projects {\n projectId\n projectName\n truncatedEpics\n epics {\n epicId\n epicTitle\n openCount\n doneCount\n openPoints\n donePoints\n }\n noEpic {\n openCount\n doneCount\n openPoints\n donePoints\n }\n }\n }\n }\n`;\n\ninterface EpicBreakdown {\n epicId: string;\n epicTitle: string;\n openCount: number;\n doneCount: number;\n openPoints: number;\n donePoints: number;\n}\n\ninterface NoEpicBreakdown {\n openCount: number;\n doneCount: number;\n openPoints: number;\n donePoints: number;\n}\n\ninterface ProjectBreakdown {\n projectId: string;\n projectName: string;\n truncatedEpics: boolean;\n epics: EpicBreakdown[];\n noEpic: NoEpicBreakdown;\n}\n\ninterface WorkBreakdownData {\n planmagnetWorkBreakdown: {\n truncatedProjects: boolean;\n projects: ProjectBreakdown[];\n };\n}\n\ninterface CountsLike {\n openCount: number;\n doneCount: number;\n openPoints: number;\n donePoints: number;\n}\n\nfunction metricTotals(c: CountsLike, metric: BreakdownMetric): { size: number; done: number } {\n return metric === 'items'\n ? { size: c.openCount + c.doneCount, done: c.doneCount }\n : { size: c.openPoints + c.donePoints, done: c.donePoints };\n}\n\nfunction doneRatioOf(c: CountsLike, metric: BreakdownMetric): number | null {\n const { size, done } = metricTotals(c, metric);\n return size > 0 ? done / size : null;\n}\n\ninterface BreakdownLeafDatum extends CountsLike {\n name: string;\n size: number;\n doneRatio: number | null;\n /** null = not an individually openable work item (the project-group frame, or the \"No epic\" bucket). */\n epicId: string | null;\n isNoEpic: boolean;\n isHeader: boolean;\n projectId: string;\n projectName: string;\n}\n\ninterface BreakdownGroupDatum {\n name: string;\n projectId: string;\n children: BreakdownLeafDatum[];\n}\n\n/** A label cell reserves this share of each project's own area (never less than HEADER_MIN_SIZE). Small relative\n * to its real epic siblings so it barely affects the epics' own visual weight, but consistently sized so every\n * project's header reads the same size. */\nconst HEADER_FRACTION = 0.14;\nconst HEADER_MIN_SIZE = 1;\n\nfunction buildBreakdownGroups(\n projects: ProjectBreakdown[],\n metric: BreakdownMetric,\n noEpicLabel: string\n): BreakdownGroupDatum[] {\n const groups: BreakdownGroupDatum[] = [];\n\n for (const project of projects) {\n const epicLeaves: BreakdownLeafDatum[] = project.epics\n .map((epic) => ({\n name: epic.epicTitle,\n size: metricTotals(epic, metric).size,\n doneRatio: doneRatioOf(epic, metric),\n epicId: epic.epicId,\n isNoEpic: false,\n isHeader: false,\n projectId: project.projectId,\n projectName: project.projectName,\n openCount: epic.openCount,\n doneCount: epic.doneCount,\n openPoints: epic.openPoints,\n donePoints: epic.donePoints,\n }))\n .filter((leaf) => leaf.size > 0);\n\n const noEpicSize = metricTotals(project.noEpic, metric).size;\n if (noEpicSize > 0) {\n epicLeaves.push({\n name: noEpicLabel,\n size: noEpicSize,\n doneRatio: doneRatioOf(project.noEpic, metric),\n epicId: null,\n isNoEpic: true,\n isHeader: false,\n projectId: project.projectId,\n projectName: project.projectName,\n openCount: project.noEpic.openCount,\n doneCount: project.noEpic.doneCount,\n openPoints: project.noEpic.openPoints,\n donePoints: project.noEpic.donePoints,\n });\n }\n\n const totalSize = epicLeaves.reduce((sum, leaf) => sum + leaf.size, 0);\n if (totalSize <= 0) continue;\n\n // A small leading child in a list of much-larger siblings reliably becomes its own single-item row under\n // squarify's row-packing (recharts' Treemap layout): adding the next, much bigger sibling to that same row\n // would immediately worsen the row's aspect ratio, so squarify closes the row after just this one item —\n // which a single-item row always stretches to the full width (or height) of its parent rect. That makes\n // this synthetic \"header\" leaf a reliable label strip across the top (or side) of each project's own area,\n // without needing a second, hand-rolled layout pass. See WorkBreakdownCell's `isHeader` branch for the\n // render side of this.\n const headerLeaf: BreakdownLeafDatum = {\n name: project.projectName,\n size: Math.max(Math.round(totalSize * HEADER_FRACTION), HEADER_MIN_SIZE),\n doneRatio: null,\n epicId: null,\n isNoEpic: false,\n isHeader: true,\n projectId: project.projectId,\n projectName: project.projectName,\n openCount: 0,\n doneCount: 0,\n openPoints: 0,\n donePoints: 0,\n };\n\n groups.push({\n name: project.projectName,\n projectId: project.projectId,\n children: [headerLeaf, ...epicLeaves],\n });\n }\n\n return groups;\n}\n\nfunction ratioToFillOpacity(doneRatio: number | null): number {\n // Sequential, single-hue encoding on DONE's own chart color (the same hue statusVisual.ts assigns to the DONE\n // status everywhere else in this app): lighter = mostly open, darker = mostly done. Never fully transparent,\n // so a 0%-done cell still reads as a populated cell rather than empty space — and the exact ratio is always\n // available in the tooltip and the table below, so this fill is a supplementary channel, never the only way\n // to read it.\n if (doneRatio === null) return 0.12;\n return 0.18 + doneRatio * 0.72;\n}\n\ninterface WorkBreakdownCellProps {\n x?: number;\n y?: number;\n width?: number;\n height?: number;\n depth?: number;\n name?: string;\n isHeader?: boolean;\n isNoEpic?: boolean;\n epicId?: string | null;\n doneRatio?: number | null;\n onOpenEpic: (epicId: string) => void;\n noEpicLabel: string;\n}\n\nfunction WorkBreakdownCell(props: WorkBreakdownCellProps) {\n const {\n x = 0,\n y = 0,\n width = 0,\n height = 0,\n depth = 0,\n name = '',\n isHeader = false,\n isNoEpic = false,\n epicId = null,\n doneRatio = null,\n onOpenEpic,\n noEpicLabel,\n } = props;\n\n if (width <= 0 || height <= 0) return null;\n\n if (depth === 0) {\n // recharts wraps the ENTIRE `data` array (every project combined) in one synthetic root at depth 0\n // (see `computeNode` / `getDerivedStateFromProps` in recharts' own Treemap source) — this fires exactly\n // once for the whole chart area, never once per project. It has no boundary of its own to draw (a rect\n // here would be one border around every project combined, not a per-project frame), so render nothing.\n return null;\n }\n\n if (depth === 1) {\n // The real per-project frame: recharts assigns depth 1 to each top-level entry of `data` — i.e. one\n // `BreakdownGroupDatum` per project — so this branch fires once per project, at that project's own\n // bounds. Drawn first (recharts renders a non-leaf node's own content, then its children, in that\n // order), so the header + epic leaves at depth 2 paint on top of it within the same bounds.\n // Never filled: fill is reserved for done-ratio below, so the frame reads purely as a boundary.\n return (\n <rect\n x={x}\n y={y}\n width={width}\n height={height}\n rx={4}\n className=\"fill-none stroke-border\"\n strokeWidth={1}\n />\n );\n }\n\n const label = isNoEpic ? noEpicLabel : name;\n const showLabel = width > 28 && height > 14;\n const fitChars = Math.max(1, Math.floor((width - 8) / 6));\n const displayLabel = truncate(label, fitChars);\n\n if (isHeader) {\n return (\n <g>\n <rect x={x} y={y} width={width} height={height} className=\"fill-muted\" />\n {showLabel && (\n <text\n x={x + 4}\n y={y + height / 2}\n dy=\"0.32em\"\n className=\"fill-foreground text-[11px] font-medium\"\n >\n {displayLabel}\n </text>\n )}\n </g>\n );\n }\n\n const cellRect = (\n <rect\n x={x}\n y={y}\n width={width}\n height={height}\n className=\"fill-chart-2 stroke-background\"\n style={{ fillOpacity: ratioToFillOpacity(doneRatio) }}\n strokeWidth={2}\n />\n );\n const cellLabel = showLabel && (\n <text x={x + 4} y={y + height / 2} dy=\"0.32em\" className=\"fill-foreground text-[11px]\">\n {displayLabel}\n </text>\n );\n\n if (!epicId) {\n // \"No epic\" bucket — an aggregate of several work items, not one item, so there is nothing to navigate to.\n return (\n <g>\n {cellRect}\n {cellLabel}\n </g>\n );\n }\n\n const accessibleLabel = `${name} — ${Math.round((doneRatio ?? 0) * 100)}% done`;\n return (\n <g\n {...rowLinkProps<SVGGElement>(() => onOpenEpic(epicId), accessibleLabel)}\n className=\"cursor-pointer focus:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2\"\n >\n <title>{accessibleLabel}</title>\n {cellRect}\n {cellLabel}\n </g>\n );\n}\n\nexport function WorkBreakdownCard({\n workspaceId,\n projectId,\n}: {\n workspaceId?: string | null;\n /** ANR-09 project scope. Omitted/undefined = every project (up to 50 — see truncatedProjects below). */\n projectId?: string;\n}) {\n const { t } = useI18n();\n const navigate = usePlanMagnetNavigate();\n const [metric, setMetric] = useState<BreakdownMetric>('items');\n const { data, loading, error } = useQuery<WorkBreakdownData>(GET_WORK_BREAKDOWN, {\n variables: { workspaceId, projectIds: projectId ? [projectId] : undefined },\n skip: !workspaceId,\n });\n\n const noEpicLabel = t('planmagnet.analytics.workBreakdown.noEpic', 'No epic');\n const breakdown = data?.planmagnetWorkBreakdown;\n const projects = breakdown?.projects ?? [];\n const groups = buildBreakdownGroups(projects, metric, noEpicLabel);\n const truncatedProjectNames = projects.filter((p) => p.truncatedEpics).map((p) => p.projectName);\n\n function openEpic(epicId: string) {\n navigate(`/tasks/task-detail-page?taskId=${encodeURIComponent(epicId)}`);\n }\n\n return (\n <Card data-testid=\"work-breakdown-card\">\n <CardHeader>\n <div className=\"flex flex-wrap items-center justify-between gap-2\">\n <CardTitle className=\"flex items-center gap-2\">\n <LayoutGrid className=\"h-4 w-4\" />\n {t('planmagnet.analytics.workBreakdown.title', 'Work breakdown')}\n </CardTitle>\n <div\n className=\"flex items-center gap-1 rounded-md border p-0.5\"\n role=\"group\"\n aria-label={t('planmagnet.analytics.workBreakdown.metricLabel', 'Size by')}\n data-testid=\"viz-breakdown-metric\"\n >\n {(['items', 'points'] as const).map((m) => (\n <button\n key={m}\n type=\"button\"\n aria-pressed={metric === m}\n onClick={() => setMetric(m)}\n className={`px-2 py-1 text-xs rounded ${\n metric === m\n ? 'bg-primary text-primary-foreground'\n : 'text-muted-foreground hover:bg-muted'\n }`}\n >\n {m === 'items'\n ? t('planmagnet.analytics.workBreakdown.items', 'Items')\n : t('planmagnet.analytics.workBreakdown.points', 'Story points')}\n </button>\n ))}\n </div>\n </div>\n <p className=\"text-xs text-muted-foreground\">\n {t(\n 'planmagnet.analytics.workBreakdown.subtitle',\n 'Live open/done work, by project and epic — current state, not limited by the date range above.'\n )}\n </p>\n </CardHeader>\n <CardContent>\n {loading && !data ? (\n <div className=\"flex justify-center py-12\">\n <div className=\"animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full\" />\n </div>\n ) : error ? (\n <p className=\"text-sm text-status-error-text\">\n {isForbidden(error)\n ? t(\n 'planmagnet.analytics.workBreakdown.forbidden',\n 'You do not have access to the work breakdown for this scope.'\n )\n : `${t('planmagnet.analytics.workBreakdown.error', 'Work breakdown could not be loaded')}: ${error.message}`}\n </p>\n ) : groups.length === 0 ? (\n <p className=\"text-sm text-muted-foreground py-8 text-center\">\n {t(\n 'planmagnet.analytics.workBreakdown.empty',\n 'No live work items found for this scope.'\n )}\n </p>\n ) : (\n <div className=\"space-y-3\">\n {breakdown?.truncatedProjects && (\n <p className=\"text-xs text-status-warning-text\">\n {t(\n 'planmagnet.analytics.workBreakdown.truncatedProjects',\n 'Showing the first 50 projects with live work. Narrow by project above to see a specific one.'\n )}\n </p>\n )}\n {truncatedProjectNames.length > 0 && (\n <p className=\"text-xs text-status-warning-text\">\n {t(\n 'planmagnet.analytics.workBreakdown.truncatedEpics',\n 'Showing at most 200 epics for: {{projects}}.',\n { projects: truncatedProjectNames.join(', ') }\n )}\n </p>\n )}\n\n <div data-testid=\"viz-work-breakdown\">\n <ResponsiveContainer width=\"100%\" height={360}>\n <Treemap\n data={groups}\n dataKey=\"size\"\n type=\"flat\"\n aspectRatio={4 / 3}\n content={<WorkBreakdownCell onOpenEpic={openEpic} noEpicLabel={noEpicLabel} />}\n >\n <Tooltip\n content={({ active, payload }) => {\n if (!active || !payload || payload.length === 0) return null;\n // Treemap hands back our own leaf datum directly as `payload.payload` (recharts types it\n // as `any`); narrowed here, once.\n const node = payload[0]?.payload as BreakdownLeafDatum | undefined;\n if (!node || node.isHeader) return null;\n const label = node.isNoEpic ? noEpicLabel : node.name;\n return (\n <div className=\"rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs shadow-md\">\n <p className=\"font-medium text-popover-foreground\">{label}</p>\n {!node.isNoEpic && (\n <p className=\"text-muted-foreground\">{node.projectName}</p>\n )}\n <p className=\"mt-1\">\n {t('planmagnet.analytics.workBreakdown.tooltipOpen', 'Open')}:{' '}\n {node.openCount} ({node.openPoints}{' '}\n {t('planmagnet.analytics.workBreakdown.pts', 'pts')}) ·{' '}\n {t('planmagnet.analytics.workBreakdown.tooltipDone', 'Done')}:{' '}\n {node.doneCount} ({node.donePoints}{' '}\n {t('planmagnet.analytics.workBreakdown.pts', 'pts')})\n </p>\n <p>\n {t('planmagnet.analytics.workBreakdown.doneRatio', 'Done ratio')}:{' '}\n {node.doneRatio === null ? '—' : `${Math.round(node.doneRatio * 100)}%`}\n </p>\n </div>\n );\n }}\n />\n </Treemap>\n </ResponsiveContainer>\n </div>\n <p className=\"text-xs text-muted-foreground\">\n {t(\n 'planmagnet.analytics.workBreakdown.legend',\n 'Shade = share done (lighter → more open, darker → more done). Click an epic to open it.'\n )}\n </p>\n\n {/* Accessible table alternative — every group/epic the treemap draws, in the same data. */}\n <table className=\"w-full text-sm\" data-testid=\"viz-work-breakdown-table\">\n <caption className=\"mb-2 text-left text-xs text-muted-foreground\">\n {t(\n 'planmagnet.analytics.workBreakdown.tableCaption',\n 'Work breakdown by project and epic'\n )}\n </caption>\n <thead>\n <tr className=\"text-left text-xs text-muted-foreground\">\n <th className=\"py-1 pr-2 font-normal\">\n {t('planmagnet.analytics.workBreakdown.project', 'Project')}\n </th>\n <th className=\"py-1 pr-2 font-normal\">\n {t('planmagnet.analytics.workBreakdown.epic', 'Epic')}\n </th>\n <th className=\"py-1 text-right font-normal\">\n {t('planmagnet.analytics.workBreakdown.tooltipOpen', 'Open')}\n </th>\n <th className=\"py-1 text-right font-normal\">\n {t('planmagnet.analytics.workBreakdown.tooltipDone', 'Done')}\n </th>\n <th className=\"py-1 text-right font-normal\">\n {t('planmagnet.analytics.workBreakdown.doneRatio', 'Done ratio')}\n </th>\n </tr>\n </thead>\n <tbody>\n {groups.flatMap((g) =>\n g.children\n .filter((leaf) => !leaf.isHeader)\n .map((leaf) => {\n const epicId = leaf.epicId;\n const open = metric === 'items' ? leaf.openCount : leaf.openPoints;\n const done = metric === 'items' ? leaf.doneCount : leaf.donePoints;\n return (\n <tr key={`${g.projectId}-${epicId ?? 'no-epic'}`} className=\"border-t\">\n <td className=\"py-1.5 pr-2\">{g.name}</td>\n <td className=\"py-1.5 pr-2\">\n {epicId ? (\n <button\n type=\"button\"\n className=\"text-left text-primary hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary\"\n onClick={() => openEpic(epicId)}\n >\n {leaf.name}\n </button>\n ) : (\n noEpicLabel\n )}\n </td>\n <td className=\"py-1.5 text-right tabular-nums\">{open}</td>\n <td className=\"py-1.5 text-right tabular-nums\">{done}</td>\n <td className=\"py-1.5 text-right tabular-nums\">\n {leaf.doneRatio === null ? '—' : `${Math.round(leaf.doneRatio * 100)}%`}\n </td>\n </tr>\n );\n })\n )}\n </tbody>\n </table>\n </div>\n )}\n </CardContent>\n </Card>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"WorkBreakdownCard.js","names":[],"sources":["../../../../src/planmagnet/pages/analytics/WorkBreakdownCard.tsx"],"sourcesContent":["import { useState } from 'react';\nimport { gql } from '@apollo/client';\nimport { useQuery } from '@apollo/client/react';\nimport { Card, CardContent, CardHeader, CardTitle } from '@burdenoff/fe-libs/ui';\nimport { useI18n } from '@burdenoff/fe-libs/shared/providers/shell/I18nProvider';\nimport { LayoutGrid } from 'lucide-react';\nimport { ResponsiveContainer, Tooltip, Treemap } from 'recharts';\nimport { usePlanMagnetNavigate } from '../../providers/PlanMagnetProvider';\nimport { rowLinkProps } from '../../utils/rowNavigation';\nimport { isForbidden } from '../../utils/graphqlErrors';\nimport { truncate } from '../../dependency/DependencyGraph';\n\n/**\n * ANR-24: work breakdown Treemap (wspace-planmagnet-svc#65 `planmagnetWorkBreakdown`) — live (not date-ranged;\n * see the field's own doc comment) open/done counts grouped project -> epic, with a \"No epic\" bucket per\n * project for items an epic rollup doesn't reach (see PlanmagnetNoEpicWorkBreakdown's doc comment: direct\n * children only, same as PlanmagnetEpicWorkBreakdown — a sub-task nested two levels under an epic falls here\n * too, not under its grandparent epic).\n *\n * recharts' Treemap computes a node's own size as the SUM of its children's sizes when `node.children` is set\n * (see `computeNode` in recharts' source) — so each project's own cell size, and its done-ratio color, both\n * come from summing this card's own visualized epic + \"No epic\" leaves, not from the server's separate\n * project-level totals (which include every live item in the project, not only the ones a leaf here\n * represents — see PlanmagnetProjectWorkBreakdown's doc comment). What the treemap draws and what the table\n * below it lists are therefore always the same numbers.\n */\n\ntype BreakdownMetric = 'items' | 'points';\n\nconst GET_WORK_BREAKDOWN = gql`\n query GetAnalyticsWorkBreakdown($workspaceId: ID!, $projectIds: [ID!]) {\n planmagnetWorkBreakdown(workspaceId: $workspaceId, projectIds: $projectIds) {\n truncatedProjects\n projects {\n projectId\n projectName\n truncatedEpics\n epics {\n epicId\n epicTitle\n openCount\n doneCount\n openPoints\n donePoints\n }\n noEpic {\n openCount\n doneCount\n openPoints\n donePoints\n }\n }\n }\n }\n`;\n\ninterface EpicBreakdown {\n epicId: string;\n epicTitle: string;\n openCount: number;\n doneCount: number;\n openPoints: number;\n donePoints: number;\n}\n\ninterface NoEpicBreakdown {\n openCount: number;\n doneCount: number;\n openPoints: number;\n donePoints: number;\n}\n\ninterface ProjectBreakdown {\n projectId: string;\n projectName: string;\n truncatedEpics: boolean;\n epics: EpicBreakdown[];\n noEpic: NoEpicBreakdown;\n}\n\ninterface WorkBreakdownData {\n planmagnetWorkBreakdown: {\n truncatedProjects: boolean;\n projects: ProjectBreakdown[];\n };\n}\n\ninterface CountsLike {\n openCount: number;\n doneCount: number;\n openPoints: number;\n donePoints: number;\n}\n\nfunction metricTotals(c: CountsLike, metric: BreakdownMetric): { size: number; done: number } {\n return metric === 'items'\n ? { size: c.openCount + c.doneCount, done: c.doneCount }\n : { size: c.openPoints + c.donePoints, done: c.donePoints };\n}\n\nfunction doneRatioOf(c: CountsLike, metric: BreakdownMetric): number | null {\n const { size, done } = metricTotals(c, metric);\n return size > 0 ? done / size : null;\n}\n\ninterface BreakdownLeafDatum extends CountsLike {\n name: string;\n size: number;\n doneRatio: number | null;\n /** null = not an individually openable work item (the project-group frame, or the \"No epic\" bucket). */\n epicId: string | null;\n isNoEpic: boolean;\n isHeader: boolean;\n projectId: string;\n projectName: string;\n}\n\ninterface BreakdownGroupDatum {\n name: string;\n projectId: string;\n children: BreakdownLeafDatum[];\n}\n\n/** A label cell reserves this share of each project's own area (never less than HEADER_MIN_SIZE). Small relative\n * to its real epic siblings so it barely affects the epics' own visual weight, but consistently sized so every\n * project's header reads the same size. */\nconst HEADER_FRACTION = 0.14;\nconst HEADER_MIN_SIZE = 1;\n\nfunction buildBreakdownGroups(\n projects: ProjectBreakdown[],\n metric: BreakdownMetric,\n noEpicLabel: string\n): BreakdownGroupDatum[] {\n const groups: BreakdownGroupDatum[] = [];\n\n for (const project of projects) {\n const epicLeaves: BreakdownLeafDatum[] = project.epics\n .map((epic) => ({\n name: epic.epicTitle,\n size: metricTotals(epic, metric).size,\n doneRatio: doneRatioOf(epic, metric),\n epicId: epic.epicId,\n isNoEpic: false,\n isHeader: false,\n projectId: project.projectId,\n projectName: project.projectName,\n openCount: epic.openCount,\n doneCount: epic.doneCount,\n openPoints: epic.openPoints,\n donePoints: epic.donePoints,\n }))\n .filter((leaf) => leaf.size > 0);\n\n const noEpicSize = metricTotals(project.noEpic, metric).size;\n if (noEpicSize > 0) {\n epicLeaves.push({\n name: noEpicLabel,\n size: noEpicSize,\n doneRatio: doneRatioOf(project.noEpic, metric),\n epicId: null,\n isNoEpic: true,\n isHeader: false,\n projectId: project.projectId,\n projectName: project.projectName,\n openCount: project.noEpic.openCount,\n doneCount: project.noEpic.doneCount,\n openPoints: project.noEpic.openPoints,\n donePoints: project.noEpic.donePoints,\n });\n }\n\n const totalSize = epicLeaves.reduce((sum, leaf) => sum + leaf.size, 0);\n if (totalSize <= 0) continue;\n\n // A small leading child in a list of much-larger siblings reliably becomes its own single-item row under\n // squarify's row-packing (recharts' Treemap layout): adding the next, much bigger sibling to that same row\n // would immediately worsen the row's aspect ratio, so squarify closes the row after just this one item —\n // which a single-item row always stretches to the full width (or height) of its parent rect. That makes\n // this synthetic \"header\" leaf a reliable label strip across the top (or side) of each project's own area,\n // without needing a second, hand-rolled layout pass. See WorkBreakdownCell's `isHeader` branch for the\n // render side of this.\n const headerLeaf: BreakdownLeafDatum = {\n name: project.projectName,\n size: Math.max(Math.round(totalSize * HEADER_FRACTION), HEADER_MIN_SIZE),\n doneRatio: null,\n epicId: null,\n isNoEpic: false,\n isHeader: true,\n projectId: project.projectId,\n projectName: project.projectName,\n openCount: 0,\n doneCount: 0,\n openPoints: 0,\n donePoints: 0,\n };\n\n groups.push({\n name: project.projectName,\n projectId: project.projectId,\n children: [headerLeaf, ...epicLeaves],\n });\n }\n\n return groups;\n}\n\nfunction ratioToFillOpacity(doneRatio: number | null): number {\n // Sequential, single-hue encoding on DONE's own chart color (the same hue statusVisual.ts assigns to the DONE\n // status everywhere else in this app): lighter = mostly open, darker = mostly done. Never fully transparent,\n // so a 0%-done cell still reads as a populated cell rather than empty space — and the exact ratio is always\n // available in the tooltip and the table below, so this fill is a supplementary channel, never the only way\n // to read it.\n if (doneRatio === null) return 0.12;\n return 0.18 + doneRatio * 0.72;\n}\n\ninterface WorkBreakdownCellProps {\n x?: number;\n y?: number;\n width?: number;\n height?: number;\n depth?: number;\n name?: string;\n isHeader?: boolean;\n isNoEpic?: boolean;\n epicId?: string | null;\n doneRatio?: number | null;\n onOpenEpic: (epicId: string) => void;\n noEpicLabel: string;\n}\n\nfunction WorkBreakdownCell(props: WorkBreakdownCellProps) {\n const {\n x = 0,\n y = 0,\n width = 0,\n height = 0,\n depth = 0,\n name = '',\n isHeader = false,\n isNoEpic = false,\n epicId = null,\n doneRatio = null,\n onOpenEpic,\n noEpicLabel,\n } = props;\n\n if (width <= 0 || height <= 0) return null;\n\n if (depth === 0) {\n // recharts wraps the ENTIRE `data` array (every project combined) in one synthetic root at depth 0\n // (see `computeNode` / `getDerivedStateFromProps` in recharts' own Treemap source) — this fires exactly\n // once for the whole chart area, never once per project. It has no boundary of its own to draw (a rect\n // here would be one border around every project combined, not a per-project frame), so render nothing.\n return null;\n }\n\n if (depth === 1) {\n // The real per-project frame: recharts assigns depth 1 to each top-level entry of `data` — i.e. one\n // `BreakdownGroupDatum` per project — so this branch fires once per project, at that project's own\n // bounds. Drawn first (recharts renders a non-leaf node's own content, then its children, in that\n // order), so the header + epic leaves at depth 2 paint on top of it within the same bounds.\n // Never filled: fill is reserved for done-ratio below, so the frame reads purely as a boundary.\n return (\n <rect\n x={x}\n y={y}\n width={width}\n height={height}\n rx={4}\n className=\"fill-none stroke-border\"\n strokeWidth={1}\n />\n );\n }\n\n const label = isNoEpic ? noEpicLabel : name;\n const showLabel = width > 28 && height > 14;\n const fitChars = Math.max(1, Math.floor((width - 8) / 6));\n const displayLabel = truncate(label, fitChars);\n\n if (isHeader) {\n return (\n <g>\n <rect x={x} y={y} width={width} height={height} className=\"fill-muted\" />\n {showLabel && (\n <text\n x={x + 4}\n y={y + height / 2}\n dy=\"0.32em\"\n className=\"fill-foreground text-[11px] font-medium\"\n >\n {displayLabel}\n </text>\n )}\n </g>\n );\n }\n\n const cellRect = (\n <rect\n x={x}\n y={y}\n width={width}\n height={height}\n className=\"stroke-background\"\n style={{ fill: 'var(--color-chart-2)', fillOpacity: ratioToFillOpacity(doneRatio) }}\n strokeWidth={2}\n />\n );\n const cellLabel = showLabel && (\n <text x={x + 4} y={y + height / 2} dy=\"0.32em\" className=\"fill-foreground text-[11px]\">\n {displayLabel}\n </text>\n );\n\n if (!epicId) {\n // \"No epic\" bucket — an aggregate of several work items, not one item, so there is nothing to navigate to.\n return (\n <g>\n {cellRect}\n {cellLabel}\n </g>\n );\n }\n\n const accessibleLabel = `${name} — ${Math.round((doneRatio ?? 0) * 100)}% done`;\n return (\n <g\n {...rowLinkProps<SVGGElement>(() => onOpenEpic(epicId), accessibleLabel)}\n className=\"cursor-pointer focus:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2\"\n >\n <title>{accessibleLabel}</title>\n {cellRect}\n {cellLabel}\n </g>\n );\n}\n\nexport function WorkBreakdownCard({\n workspaceId,\n projectId,\n}: {\n workspaceId?: string | null;\n /** ANR-09 project scope. Omitted/undefined = every project (up to 50 — see truncatedProjects below). */\n projectId?: string;\n}) {\n const { t } = useI18n();\n const navigate = usePlanMagnetNavigate();\n const [metric, setMetric] = useState<BreakdownMetric>('items');\n const { data, loading, error } = useQuery<WorkBreakdownData>(GET_WORK_BREAKDOWN, {\n variables: { workspaceId, projectIds: projectId ? [projectId] : undefined },\n skip: !workspaceId,\n });\n\n const noEpicLabel = t('planmagnet.analytics.workBreakdown.noEpic', 'No epic');\n const breakdown = data?.planmagnetWorkBreakdown;\n const projects = breakdown?.projects ?? [];\n const groups = buildBreakdownGroups(projects, metric, noEpicLabel);\n const truncatedProjectNames = projects.filter((p) => p.truncatedEpics).map((p) => p.projectName);\n\n function openEpic(epicId: string) {\n navigate(`/tasks/task-detail-page?taskId=${encodeURIComponent(epicId)}`);\n }\n\n return (\n <Card data-testid=\"work-breakdown-card\">\n <CardHeader>\n <div className=\"flex flex-wrap items-center justify-between gap-2\">\n <CardTitle className=\"flex items-center gap-2\">\n <LayoutGrid className=\"h-4 w-4\" />\n {t('planmagnet.analytics.workBreakdown.title', 'Work breakdown')}\n </CardTitle>\n <div\n className=\"flex items-center gap-1 rounded-md border p-0.5\"\n role=\"group\"\n aria-label={t('planmagnet.analytics.workBreakdown.metricLabel', 'Size by')}\n data-testid=\"viz-breakdown-metric\"\n >\n {(['items', 'points'] as const).map((m) => (\n <button\n key={m}\n type=\"button\"\n aria-pressed={metric === m}\n onClick={() => setMetric(m)}\n className={`px-2 py-1 text-xs rounded ${\n metric === m\n ? 'bg-primary text-primary-foreground'\n : 'text-muted-foreground hover:bg-muted'\n }`}\n >\n {m === 'items'\n ? t('planmagnet.analytics.workBreakdown.items', 'Items')\n : t('planmagnet.analytics.workBreakdown.points', 'Story points')}\n </button>\n ))}\n </div>\n </div>\n <p className=\"text-xs text-muted-foreground\">\n {t(\n 'planmagnet.analytics.workBreakdown.subtitle',\n 'Live open/done work, by project and epic — current state, not limited by the date range above.'\n )}\n </p>\n </CardHeader>\n <CardContent>\n {loading && !data ? (\n <div className=\"flex justify-center py-12\">\n <div className=\"animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full\" />\n </div>\n ) : error ? (\n <p className=\"text-sm text-status-error-text\">\n {isForbidden(error)\n ? t(\n 'planmagnet.analytics.workBreakdown.forbidden',\n 'You do not have access to the work breakdown for this scope.'\n )\n : `${t('planmagnet.analytics.workBreakdown.error', 'Work breakdown could not be loaded')}: ${error.message}`}\n </p>\n ) : groups.length === 0 ? (\n <p className=\"text-sm text-muted-foreground py-8 text-center\">\n {t(\n 'planmagnet.analytics.workBreakdown.empty',\n 'No live work items found for this scope.'\n )}\n </p>\n ) : (\n <div className=\"space-y-3\">\n {breakdown?.truncatedProjects && (\n <p className=\"text-xs text-status-warning-text\">\n {t(\n 'planmagnet.analytics.workBreakdown.truncatedProjects',\n 'Showing the first 50 projects with live work. Narrow by project above to see a specific one.'\n )}\n </p>\n )}\n {truncatedProjectNames.length > 0 && (\n <p className=\"text-xs text-status-warning-text\">\n {t(\n 'planmagnet.analytics.workBreakdown.truncatedEpics',\n 'Showing at most 200 epics for: {{projects}}.',\n { projects: truncatedProjectNames.join(', ') }\n )}\n </p>\n )}\n\n <div data-testid=\"viz-work-breakdown\">\n <ResponsiveContainer width=\"100%\" height={360}>\n <Treemap\n data={groups}\n dataKey=\"size\"\n type=\"flat\"\n aspectRatio={4 / 3}\n content={<WorkBreakdownCell onOpenEpic={openEpic} noEpicLabel={noEpicLabel} />}\n >\n <Tooltip\n content={({ active, payload }) => {\n if (!active || !payload || payload.length === 0) return null;\n // Treemap hands back our own leaf datum directly as `payload.payload` (recharts types it\n // as `any`); narrowed here, once.\n const node = payload[0]?.payload as BreakdownLeafDatum | undefined;\n if (!node || node.isHeader) return null;\n const label = node.isNoEpic ? noEpicLabel : node.name;\n return (\n <div className=\"rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs shadow-md\">\n <p className=\"font-medium text-popover-foreground\">{label}</p>\n {!node.isNoEpic && (\n <p className=\"text-muted-foreground\">{node.projectName}</p>\n )}\n <p className=\"mt-1\">\n {t('planmagnet.analytics.workBreakdown.tooltipOpen', 'Open')}:{' '}\n {node.openCount} ({node.openPoints}{' '}\n {t('planmagnet.analytics.workBreakdown.pts', 'pts')}) ·{' '}\n {t('planmagnet.analytics.workBreakdown.tooltipDone', 'Done')}:{' '}\n {node.doneCount} ({node.donePoints}{' '}\n {t('planmagnet.analytics.workBreakdown.pts', 'pts')})\n </p>\n <p>\n {t('planmagnet.analytics.workBreakdown.doneRatio', 'Done ratio')}:{' '}\n {node.doneRatio === null ? '—' : `${Math.round(node.doneRatio * 100)}%`}\n </p>\n </div>\n );\n }}\n />\n </Treemap>\n </ResponsiveContainer>\n </div>\n <p className=\"text-xs text-muted-foreground\">\n {t(\n 'planmagnet.analytics.workBreakdown.legend',\n 'Shade = share done (lighter → more open, darker → more done). Click an epic to open it.'\n )}\n </p>\n\n {/* Accessible table alternative — every group/epic the treemap draws, in the same data. */}\n <table className=\"w-full text-sm\" data-testid=\"viz-work-breakdown-table\">\n <caption className=\"mb-2 text-left text-xs text-muted-foreground\">\n {t(\n 'planmagnet.analytics.workBreakdown.tableCaption',\n 'Work breakdown by project and epic'\n )}\n </caption>\n <thead>\n <tr className=\"text-left text-xs text-muted-foreground\">\n <th className=\"py-1 pr-2 font-normal\">\n {t('planmagnet.analytics.workBreakdown.project', 'Project')}\n </th>\n <th className=\"py-1 pr-2 font-normal\">\n {t('planmagnet.analytics.workBreakdown.epic', 'Epic')}\n </th>\n <th className=\"py-1 text-right font-normal\">\n {t('planmagnet.analytics.workBreakdown.tooltipOpen', 'Open')}\n </th>\n <th className=\"py-1 text-right font-normal\">\n {t('planmagnet.analytics.workBreakdown.tooltipDone', 'Done')}\n </th>\n <th className=\"py-1 text-right font-normal\">\n {t('planmagnet.analytics.workBreakdown.doneRatio', 'Done ratio')}\n </th>\n </tr>\n </thead>\n <tbody>\n {groups.flatMap((g) =>\n g.children\n .filter((leaf) => !leaf.isHeader)\n .map((leaf) => {\n const epicId = leaf.epicId;\n const open = metric === 'items' ? leaf.openCount : leaf.openPoints;\n const done = metric === 'items' ? leaf.doneCount : leaf.donePoints;\n return (\n <tr key={`${g.projectId}-${epicId ?? 'no-epic'}`} className=\"border-t\">\n <td className=\"py-1.5 pr-2\">{g.name}</td>\n <td className=\"py-1.5 pr-2\">\n {epicId ? (\n <button\n type=\"button\"\n className=\"text-left text-primary hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary\"\n onClick={() => openEpic(epicId)}\n >\n {leaf.name}\n </button>\n ) : (\n noEpicLabel\n )}\n </td>\n <td className=\"py-1.5 text-right tabular-nums\">{open}</td>\n <td className=\"py-1.5 text-right tabular-nums\">{done}</td>\n <td className=\"py-1.5 text-right tabular-nums\">\n {leaf.doneRatio === null ? '—' : `${Math.round(leaf.doneRatio * 100)}%`}\n </td>\n </tr>\n );\n })\n )}\n </tbody>\n </table>\n </div>\n )}\n </CardContent>\n </Card>\n );\n}\n"],"mappings":"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"}
@@ -4,21 +4,20 @@
4
4
  * re-assigned from whichever statuses happen to appear in a given project/graph — a categorical channel keeps the
5
5
  * same color for the same identity everywhere it shows up.
6
6
  *
7
- * Values are complete literal Tailwind class names (never built with a template string): Tailwind's build scans
8
- * source text for whole class-name tokens, so `` `fill-${status}` `` would never be picked up and would ship
9
- * invisible/unstyled in production while every check here still passes.
7
+ * Values are CSS custom-property references to fe-libs' chart token ramp (`var(--color-chart-N)`), applied through
8
+ * inline `style` (`fill`, `stroke`, `backgroundColor`) — NOT Tailwind utilities. The host apps' Tailwind themes do
9
+ * not register `--color-chart-*`, so chart utility classes are never generated: measured in prod 2026-09-30, no
10
+ * such rule existed in the served CSS and every chart mark rendered black. The token variables themselves are
11
+ * always defined (fe-libs tokens.css), so referencing them directly works in every host.
10
12
  *
11
13
  * Colors come from fe-libs' chart token ramp (`chart-1`..`chart-12`), not the `status-*` tokens — those are
12
14
  * reserved for good/warning/serious/critical severity (see `dev/skills` dataviz guidance) and workflow status is
13
15
  * an identity dimension, not a severity one.
14
16
  */
15
17
  export interface StatusVisual {
16
- /** Fill class for the node/bar body. */
17
- fill: string;
18
- /** Stroke class matching `fill`, for outlines and edge/line marks of the same status. */
19
- stroke: string;
20
- /** Solid swatch class for a small legend dot. */
21
- swatch: string;
18
+ /** The status's chart color as a CSS value (`var(--color-chart-N)`) — use as an SVG `fill`/`stroke` or a legend
19
+ * swatch's `backgroundColor` through inline style. */
20
+ color: string;
22
21
  }
23
22
  export declare function statusVisual(status: string): StatusVisual;
24
23
  /** Every status with an assigned visual, in the fixed legend order. */
@@ -1 +1 @@
1
- {"version":3,"file":"statusColor.d.ts","sourceRoot":"","sources":["../../../src/planmagnet/utils/statusColor.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,YAAY;IAC3B,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,yFAAyF;IACzF,MAAM,EAAE,MAAM,CAAC;IACf,iDAAiD;IACjD,MAAM,EAAE,MAAM,CAAC;CAChB;AAoBD,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,CAEzD;AAED,uEAAuE;AACvE,eAAO,MAAM,mBAAmB,EAAE,SAAS,MAAM,EAAuB,CAAC"}
1
+ {"version":3,"file":"statusColor.d.ts","sourceRoot":"","sources":["../../../src/planmagnet/utils/statusColor.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,YAAY;IAC3B;0DACsD;IACtD,KAAK,EAAE,MAAM,CAAC;CACf;AAgBD,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,CAEzD;AAED,uEAAuE;AACvE,eAAO,MAAM,mBAAmB,EAAE,SAAS,MAAM,EAAuB,CAAC"}
@@ -1,46 +1,14 @@
1
1
  import { WORK_ITEM_STATUSES as e } from "../controls/WorkItemControls.js";
2
2
  //#region src/planmagnet/utils/statusColor.ts
3
3
  var t = {
4
- BACKLOG: {
5
- fill: "fill-chart-6",
6
- stroke: "stroke-chart-6",
7
- swatch: "bg-chart-6"
8
- },
9
- TODO: {
10
- fill: "fill-chart-1",
11
- stroke: "stroke-chart-1",
12
- swatch: "bg-chart-1"
13
- },
14
- IN_PROGRESS: {
15
- fill: "fill-chart-4",
16
- stroke: "stroke-chart-4",
17
- swatch: "bg-chart-4"
18
- },
19
- IN_REVIEW: {
20
- fill: "fill-chart-3",
21
- stroke: "stroke-chart-3",
22
- swatch: "bg-chart-3"
23
- },
24
- TESTING: {
25
- fill: "fill-chart-9",
26
- stroke: "stroke-chart-9",
27
- swatch: "bg-chart-9"
28
- },
29
- DONE: {
30
- fill: "fill-chart-2",
31
- stroke: "stroke-chart-2",
32
- swatch: "bg-chart-2"
33
- },
34
- CANCELLED: {
35
- fill: "fill-chart-10",
36
- stroke: "stroke-chart-10",
37
- swatch: "bg-chart-10"
38
- }
39
- }, n = {
40
- fill: "fill-chart-12",
41
- stroke: "stroke-chart-12",
42
- swatch: "bg-chart-12"
43
- };
4
+ BACKLOG: { color: "var(--color-chart-6)" },
5
+ TODO: { color: "var(--color-chart-1)" },
6
+ IN_PROGRESS: { color: "var(--color-chart-4)" },
7
+ IN_REVIEW: { color: "var(--color-chart-3)" },
8
+ TESTING: { color: "var(--color-chart-9)" },
9
+ DONE: { color: "var(--color-chart-2)" },
10
+ CANCELLED: { color: "var(--color-chart-10)" }
11
+ }, n = { color: "var(--color-chart-12)" };
44
12
  function r(e) {
45
13
  return t[e] ?? n;
46
14
  }
@@ -1 +1 @@
1
- {"version":3,"file":"statusColor.js","names":[],"sources":["../../../src/planmagnet/utils/statusColor.ts"],"sourcesContent":["import { WORK_ITEM_STATUSES } from '../controls/WorkItemControls';\n\n/**\n * One fixed chart color per WorkItemStatus (PDM-27 dependency graph, ANR-24 Gantt): both visualizations color by\n * status, so they share this single mapping rather than each re-deriving one. The order is FIXED and never\n * re-assigned from whichever statuses happen to appear in a given project/graph — a categorical channel keeps the\n * same color for the same identity everywhere it shows up.\n *\n * Values are complete literal Tailwind class names (never built with a template string): Tailwind's build scans\n * source text for whole class-name tokens, so `` `fill-${status}` `` would never be picked up and would ship\n * invisible/unstyled in production while every check here still passes.\n *\n * Colors come from fe-libs' chart token ramp (`chart-1`..`chart-12`), not the `status-*` tokens — those are\n * reserved for good/warning/serious/critical severity (see `dev/skills` dataviz guidance) and workflow status is\n * an identity dimension, not a severity one.\n */\nexport interface StatusVisual {\n /** Fill class for the node/bar body. */\n fill: string;\n /** Stroke class matching `fill`, for outlines and edge/line marks of the same status. */\n stroke: string;\n /** Solid swatch class for a small legend dot. */\n swatch: string;\n}\n\nconst STATUS_VISUALS: Record<(typeof WORK_ITEM_STATUSES)[number], StatusVisual> = {\n BACKLOG: { fill: 'fill-chart-6', stroke: 'stroke-chart-6', swatch: 'bg-chart-6' },\n TODO: { fill: 'fill-chart-1', stroke: 'stroke-chart-1', swatch: 'bg-chart-1' },\n IN_PROGRESS: { fill: 'fill-chart-4', stroke: 'stroke-chart-4', swatch: 'bg-chart-4' },\n IN_REVIEW: { fill: 'fill-chart-3', stroke: 'stroke-chart-3', swatch: 'bg-chart-3' },\n TESTING: { fill: 'fill-chart-9', stroke: 'stroke-chart-9', swatch: 'bg-chart-9' },\n DONE: { fill: 'fill-chart-2', stroke: 'stroke-chart-2', swatch: 'bg-chart-2' },\n CANCELLED: { fill: 'fill-chart-10', stroke: 'stroke-chart-10', swatch: 'bg-chart-10' },\n};\n\n/** A status the API returns that this map doesn't know yet (see WorkItemStatusCount's own doc comment on the\n * same risk) falls back to this rather than throwing or rendering unstyled. */\nconst FALLBACK_VISUAL: StatusVisual = {\n fill: 'fill-chart-12',\n stroke: 'stroke-chart-12',\n swatch: 'bg-chart-12',\n};\n\nexport function statusVisual(status: string): StatusVisual {\n return (STATUS_VISUALS as Record<string, StatusVisual>)[status] ?? FALLBACK_VISUAL;\n}\n\n/** Every status with an assigned visual, in the fixed legend order. */\nexport const STATUS_LEGEND_ORDER: readonly string[] = WORK_ITEM_STATUSES;\n"],"mappings":";;AAyBA,IAAM,IAA4E;CAChF,SAAS;EAAE,MAAM;EAAgB,QAAQ;EAAkB,QAAQ;EAAc;CACjF,MAAM;EAAE,MAAM;EAAgB,QAAQ;EAAkB,QAAQ;EAAc;CAC9E,aAAa;EAAE,MAAM;EAAgB,QAAQ;EAAkB,QAAQ;EAAc;CACrF,WAAW;EAAE,MAAM;EAAgB,QAAQ;EAAkB,QAAQ;EAAc;CACnF,SAAS;EAAE,MAAM;EAAgB,QAAQ;EAAkB,QAAQ;EAAc;CACjF,MAAM;EAAE,MAAM;EAAgB,QAAQ;EAAkB,QAAQ;EAAc;CAC9E,WAAW;EAAE,MAAM;EAAiB,QAAQ;EAAmB,QAAQ;EAAe;CACvF,EAIK,IAAgC;CACpC,MAAM;CACN,QAAQ;CACR,QAAQ;CACT;AAED,SAAgB,EAAa,GAA8B;AACzD,QAAQ,EAAgD,MAAW;;AAIrE,IAAa,IAAyC"}
1
+ {"version":3,"file":"statusColor.js","names":[],"sources":["../../../src/planmagnet/utils/statusColor.ts"],"sourcesContent":["import { WORK_ITEM_STATUSES } from '../controls/WorkItemControls';\n\n/**\n * One fixed chart color per WorkItemStatus (PDM-27 dependency graph, ANR-24 Gantt): both visualizations color by\n * status, so they share this single mapping rather than each re-deriving one. The order is FIXED and never\n * re-assigned from whichever statuses happen to appear in a given project/graph — a categorical channel keeps the\n * same color for the same identity everywhere it shows up.\n *\n * Values are CSS custom-property references to fe-libs' chart token ramp (`var(--color-chart-N)`), applied through\n * inline `style` (`fill`, `stroke`, `backgroundColor`) — NOT Tailwind utilities. The host apps' Tailwind themes do\n * not register `--color-chart-*`, so chart utility classes are never generated: measured in prod 2026-09-30, no\n * such rule existed in the served CSS and every chart mark rendered black. The token variables themselves are\n * always defined (fe-libs tokens.css), so referencing them directly works in every host.\n *\n * Colors come from fe-libs' chart token ramp (`chart-1`..`chart-12`), not the `status-*` tokens — those are\n * reserved for good/warning/serious/critical severity (see `dev/skills` dataviz guidance) and workflow status is\n * an identity dimension, not a severity one.\n */\nexport interface StatusVisual {\n /** The status's chart color as a CSS value (`var(--color-chart-N)`) — use as an SVG `fill`/`stroke` or a legend\n * swatch's `backgroundColor` through inline style. */\n color: string;\n}\n\nconst STATUS_VISUALS: Record<(typeof WORK_ITEM_STATUSES)[number], StatusVisual> = {\n BACKLOG: { color: 'var(--color-chart-6)' },\n TODO: { color: 'var(--color-chart-1)' },\n IN_PROGRESS: { color: 'var(--color-chart-4)' },\n IN_REVIEW: { color: 'var(--color-chart-3)' },\n TESTING: { color: 'var(--color-chart-9)' },\n DONE: { color: 'var(--color-chart-2)' },\n CANCELLED: { color: 'var(--color-chart-10)' },\n};\n\n/** A status the API returns that this map doesn't know yet (see WorkItemStatusCount's own doc comment on the\n * same risk) falls back to this rather than throwing or rendering unstyled. */\nconst FALLBACK_VISUAL: StatusVisual = { color: 'var(--color-chart-12)' };\n\nexport function statusVisual(status: string): StatusVisual {\n return (STATUS_VISUALS as Record<string, StatusVisual>)[status] ?? FALLBACK_VISUAL;\n}\n\n/** Every status with an assigned visual, in the fixed legend order. */\nexport const STATUS_LEGEND_ORDER: readonly string[] = WORK_ITEM_STATUSES;\n"],"mappings":";;AAwBA,IAAM,IAA4E;CAChF,SAAS,EAAE,OAAO,wBAAwB;CAC1C,MAAM,EAAE,OAAO,wBAAwB;CACvC,aAAa,EAAE,OAAO,wBAAwB;CAC9C,WAAW,EAAE,OAAO,wBAAwB;CAC5C,SAAS,EAAE,OAAO,wBAAwB;CAC1C,MAAM,EAAE,OAAO,wBAAwB;CACvC,WAAW,EAAE,OAAO,yBAAyB;CAC9C,EAIK,IAAgC,EAAE,OAAO,yBAAyB;AAExE,SAAgB,EAAa,GAA8B;AACzD,QAAQ,EAAgD,MAAW;;AAIrE,IAAa,IAAyC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@burdenoff/microfe-planmagnet",
3
- "version": "2026.930.22",
3
+ "version": "2026.930.23",
4
4
  "description": "Burdenoff PlanMagnet Microfrontend",
5
5
  "type": "module",
6
6
  "files": [