From 240131ae705d02cce1201d8378b51b5fc78bfba2 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 14 Apr 2026 16:41:18 -0400 Subject: [PATCH] =?UTF-8?q?feat(rflows):=20outcome=E2=86=92rTasks=20integr?= =?UTF-8?q?ation=20+=20overflow=20pipe=20interactivity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add linkedTaskIds/linkedBoardId to OutcomeNodeData (schema v5) - Enhanced outcome modal: linked tasks list, create/link/unlink actions, task picker, deep links to rTasks - 5 new API endpoints for outcome-task CRUD + board task listing - Bidirectional status sync: all linked tasks DONE → outcome completed; any IN_PROGRESS → outcome in-progress - Overflow pipe click-to-configure: popover with allocation sliders per target - Animated flow stripes on active overflow pipes (CSS keyframe + SVG dash) - Single click outcome → modal (was inline edit); dblclick still opens inline edit - Blue count badge on outcome basin when tasks linked - Outcome basin hover glow + cursor pointer Co-Authored-By: Claude Opus 4.6 --- modules/rflows/components/flows.css | 58 ++++ modules/rflows/components/folk-flows-app.ts | 352 +++++++++++++++++++- modules/rflows/lib/types.ts | 6 + modules/rflows/mod.ts | 301 +++++++++++++++++ modules/rflows/schemas.ts | 18 +- 5 files changed, 722 insertions(+), 13 deletions(-) diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css index f30e022e..b279afd8 100644 --- a/modules/rflows/components/flows.css +++ b/modules/rflows/components/flows.css @@ -1428,3 +1428,61 @@ .flows-panel-tab--left { left: 0; } .flows-panel-tab--left.panel-open { left: 380px; transition: left 0.25s ease; } .flows-panel-tab--right { right: 0; border-radius: 8px 0 0 8px; border: 1px solid var(--rs-border-strong); border-right: none; } + +/* ── Overflow pipe animations ─────────────────────────── */ +@keyframes pipe-flow { + 0% { stroke-dashoffset: 16; } + 100% { stroke-dashoffset: 0; } +} +.funnel-pipe { cursor: pointer; transition: opacity 0.2s, fill 0.2s; } +.funnel-pipe:hover { opacity: 1 !important; filter: brightness(1.2); } +.funnel-pipe--active { + animation: pipe-pulse 2s ease-in-out infinite; +} +@keyframes pipe-pulse { + 0%, 100% { opacity: 0.85; } + 50% { opacity: 1; filter: brightness(1.3); } +} +.pipe-flow-stripe { + animation: pipe-flow 0.6s linear infinite; + pointer-events: none; +} + +/* ── Outcome basin hover + cursor ─────────────────────── */ +.flow-node[data-node-id] .basin-outline { cursor: pointer; } +.flow-node:hover .basin-outline { filter: drop-shadow(0 0 6px rgba(99,102,241,0.4)); } +.flow-node:hover .node-bg { filter: brightness(1.05); } + +/* ── Linked tasks in outcome modal ────────────────────── */ +.linked-task-row { + display: flex; align-items: center; gap: 8px; padding: 8px 10px; + border: 1px solid var(--rs-border-strong); border-radius: 8px; margin-bottom: 6px; + transition: background 0.15s; +} +.linked-task-row:hover { background: var(--rs-bg-surface-sunken); } +.linked-task-title { + font-size: 12px; color: var(--rs-text-primary); flex: 1; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.linked-task-title:hover { color: var(--rflows-status-inprogress); text-decoration: underline; } +.linked-task-unlink { + background: none; border: none; color: var(--rs-text-muted); font-size: 16px; + cursor: pointer; padding: 0 4px; line-height: 1; transition: color 0.15s; +} +.linked-task-unlink:hover { color: var(--rflows-status-critical); } + +/* ── Task picker overlay ──────────────────────────────── */ +.task-picker-overlay { + margin-top: 12px; border-top: 1px solid var(--rs-border-strong); padding-top: 12px; +} +.task-picker-panel { /* inline in modal */ } +.task-picker-item { + display: flex; align-items: center; gap: 8px; padding: 8px 10px; + border-radius: 6px; cursor: pointer; transition: background 0.15s; +} +.task-picker-item:hover { background: var(--rs-bg-surface-raised); } + +/* ── rTasks badge on outcome basin ────────────────────── */ +.outcome-tasks-badge { + pointer-events: none; +} diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 015c0eaf..d5e1c67f 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -1478,12 +1478,18 @@ class FolkFlowsApp extends HTMLElement { // Cancel any pending rAF if (this._dragRafId) { cancelAnimationFrame(this._dragRafId); this._dragRafId = null; } - // Single click = select + open inline editor + // Single click = select + open editor/modal if (!wasDragged) { this.selectedNodeId = clickedNodeId; this.selectedEdgeKey = null; this.updateSelectionHighlight(); - this.enterInlineEdit(clickedNodeId); + // Outcome nodes: single click → modal; dblclick → inline edit + const clickedNode = this.nodes.find((n) => n.id === clickedNodeId); + if (clickedNode?.type === "outcome") { + this.openOutcomeModal(clickedNodeId); + } else { + this.enterInlineEdit(clickedNodeId); + } } else { // Full edge redraw for final accuracy this.redrawEdges(); @@ -1498,6 +1504,16 @@ class FolkFlowsApp extends HTMLElement { const nodeLayer = this.shadow.getElementById("node-layer"); if (nodeLayer) { nodeLayer.addEventListener("pointerdown", (e: PointerEvent) => { + // Check pipe click FIRST — overflow pipe allocation editor + const pipeEl = (e.target as Element).closest(".funnel-pipe") as SVGRectElement | null; + if (pipeEl) { + e.stopPropagation(); + const pipeNodeId = pipeEl.dataset.nodeId; + const pipeSide = pipeEl.dataset.pipe as "left" | "right"; + if (pipeNodeId) this.openPipeEditor(pipeNodeId, pipeSide, e); + return; + } + // Check port interaction FIRST const portGroup = (e.target as Element).closest(".port-group") as SVGGElement | null; if (portGroup) { @@ -1548,10 +1564,14 @@ class FolkFlowsApp extends HTMLElement { this.selectedEdgeKey = null; this.updateSelectionHighlight(); - // If click originated from HTML inside foreignObject, open inline edit but skip drag + // If click originated from HTML inside foreignObject, open editor/modal but skip drag const target = e.target as Element; if (target instanceof HTMLElement && target.closest("foreignObject")) { - this.enterInlineEdit(nodeId); + if (node.type === "outcome") { + this.openOutcomeModal(nodeId); + } else { + this.enterInlineEdit(nodeId); + } return; } @@ -2182,7 +2202,9 @@ class FolkFlowsApp extends HTMLElement { ${inflowPipeIndicator} + ${isOverflow ? `` : ""} + ${isOverflow ? `` : ""} ${overflowSpill} @@ -2293,7 +2315,8 @@ class FolkFlowsApp extends HTMLElement { ${overflowSplash} - ${oOverflowPipeW > 0 ? `` : ""} + ${oOverflowPipeW > 0 ? ` + ` : ""}
@@ -2305,6 +2328,11 @@ class FolkFlowsApp extends HTMLElement { ${Math.round(fillPct * 100)}% ${dollarLabel} + ${(d.linkedTaskIds?.length || 0) > 0 ? ` + + + ${d.linkedTaskIds!.length} + ` : ""} ${this.renderPortsSvg(n)} `; } @@ -4697,14 +4725,16 @@ class FolkFlowsApp extends HTMLElement { phasesHtml += ``; } + const linkedCount = d.linkedTaskIds?.length || 0; + const backdrop = document.createElement("div"); backdrop.className = "flows-modal-backdrop"; backdrop.id = "flows-modal"; - backdrop.innerHTML = `
+ backdrop.innerHTML = `
-
- ${statusLabel} - ${this.esc(d.label)} +
+ ${statusLabel} + ${this.esc(d.label)}
@@ -4720,10 +4750,195 @@ class FolkFlowsApp extends HTMLElement {
Phases
${phasesHtml}
` : ""} + +
+
+
+ Linked Tasks ${linkedCount > 0 ? `(${linkedCount})` : ""} +
+
+ + +
+
+
+
Loading tasks...
+
+
+ + ${linkedCount > 0 ? `` : ""}
`; this.shadow.appendChild(backdrop); this.attachOutcomeModalListeners(backdrop, nodeId); + + // Load linked tasks async + this.loadLinkedTasks(backdrop, nodeId); + } + + private async loadLinkedTasks(backdrop: HTMLElement, nodeId: string) { + const container = backdrop.querySelector(".outcome-linked-tasks") as HTMLElement; + if (!container) return; + + try { + const res = await fetch(`/api/flows/outcome-tasks?space=${encodeURIComponent(this.space)}&outcomeId=${encodeURIComponent(nodeId)}`); + if (!res.ok) throw new Error("Failed to load"); + const data = await res.json(); + + // Store boards data for picker + (container as any).__boards = data.boards || []; + + if (!data.tasks || data.tasks.length === 0) { + container.innerHTML = `
No linked tasks yet. Link existing tasks or create new ones.
`; + return; + } + + const statusColors: Record = { + TODO: { bg: "rgba(100,116,139,0.15)", fg: "#94a3b8" }, + IN_PROGRESS: { bg: "rgba(59,130,246,0.15)", fg: "#3b82f6" }, + DONE: { bg: "rgba(16,185,129,0.15)", fg: "#10b981" }, + }; + + container.innerHTML = data.tasks.map((t: any) => { + const sc = statusColors[t.status] || statusColors.TODO; + const priorityIcon = t.priority === 'HIGH' ? '!' : t.priority === 'MEDIUM' ? '!' : ''; + return `
+
+ ${priorityIcon} + ${this.esc(t.title)} + ${t.status.replace("_", " ")} +
+ +
`; + }).join(""); + + // Attach click handlers for task navigation + container.querySelectorAll(".linked-task-title").forEach((el) => { + (el as HTMLElement).style.cursor = "pointer"; + el.addEventListener("click", () => { + const boardId = (el as HTMLElement).dataset.board; + const taskId = (el as HTMLElement).dataset.task; + window.location.href = `/${this.space}/rtasks?board=${boardId}&task=${taskId}`; + }); + }); + + // Unlink buttons + container.querySelectorAll(".linked-task-unlink").forEach((btn) => { + btn.addEventListener("click", async () => { + const ref = (btn as HTMLElement).dataset.unlinkRef; + if (!ref) return; + const token = getAccessToken(); + if (!token) return; + await fetch("/api/flows/outcome-tasks/unlink", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ space: this.space, outcomeId: nodeId, ref }), + }); + // Update local data + const node = this.nodes.find((n) => n.id === nodeId); + if (node) { + const od = node.data as OutcomeNodeData; + if (od.linkedTaskIds) { + const idx = od.linkedTaskIds.indexOf(ref); + if (idx >= 0) od.linkedTaskIds.splice(idx, 1); + } + } + this.loadLinkedTasks(backdrop, nodeId); + this.drawCanvasContent(); + }); + }); + } catch { + container.innerHTML = `
Could not load linked tasks
`; + } + } + + private async openTaskPicker(backdrop: HTMLElement, nodeId: string) { + const container = backdrop.querySelector(".outcome-linked-tasks") as HTMLElement; + const boards: { id: string; name: string }[] = (container as any)?.__boards || []; + if (boards.length === 0) { + alert("No rTasks boards found in this space."); + return; + } + + // Use first board by default or the linked board + const node = this.nodes.find((n) => n.id === nodeId); + const od = node?.data as OutcomeNodeData | undefined; + const defaultBoard = od?.linkedBoardId || boards[0]?.id || ""; + + // Create picker dropdown + const picker = document.createElement("div"); + picker.className = "task-picker-overlay"; + picker.innerHTML = `
+
+ Link a Task + +
+
+ +
+
+
Loading...
+
+
`; + + backdrop.querySelector(".flows-modal")?.appendChild(picker); + + const loadBoardTasks = async (boardId: string) => { + const listEl = picker.querySelector(".task-picker-list") as HTMLElement; + try { + const res = await fetch(`/api/flows/board-tasks?space=${encodeURIComponent(this.space)}&boardId=${encodeURIComponent(boardId)}&outcomeId=${encodeURIComponent(nodeId)}`); + const data = await res.json(); + if (!data.tasks || data.tasks.length === 0) { + listEl.innerHTML = `
No unlinked tasks in this board
`; + return; + } + const statusColors: Record = { TODO: "#94a3b8", IN_PROGRESS: "#3b82f6", DONE: "#10b981" }; + listEl.innerHTML = data.tasks.map((t: any) => ` +
+ ${this.esc(t.title)} + ${t.status.replace("_", " ")} +
+ `).join(""); + + listEl.querySelectorAll(".task-picker-item").forEach((item) => { + item.addEventListener("click", async () => { + const taskId = (item as HTMLElement).dataset.taskId; + const bid = (item as HTMLElement).dataset.boardId; + if (!taskId || !bid) return; + const token = getAccessToken(); + if (!token) { alert("Please sign in."); return; } + await fetch("/api/flows/outcome-tasks/link", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ space: this.space, outcomeId: nodeId, boardId: bid, taskId }), + }); + // Update local data + const n = this.nodes.find((nd) => nd.id === nodeId); + if (n) { + const od2 = n.data as OutcomeNodeData; + if (!od2.linkedTaskIds) od2.linkedTaskIds = []; + od2.linkedTaskIds.push(`${bid}:${taskId}`); + } + picker.remove(); + this.loadLinkedTasks(backdrop, nodeId); + this.drawCanvasContent(); + }); + }); + } catch { + listEl.innerHTML = `
Failed to load tasks
`; + } + }; + + picker.querySelector("[data-picker-close]")?.addEventListener("click", () => picker.remove()); + picker.querySelector("[data-picker-board]")?.addEventListener("change", (e) => { + loadBoardTasks((e.target as HTMLSelectElement).value); + }); + + loadBoardTasks(defaultBoard); } private attachOutcomeModalListeners(backdrop: HTMLElement, nodeId: string) { @@ -4764,7 +4979,7 @@ class FolkFlowsApp extends HTMLElement { }); }); - // Add task + // Add phase task backdrop.querySelectorAll("[data-add-task]").forEach((btn) => { btn.addEventListener("click", () => { const phaseIdx = parseInt((btn as HTMLElement).dataset.addTask!, 10); @@ -4790,6 +5005,123 @@ class FolkFlowsApp extends HTMLElement { this.drawCanvasContent(); } }); + + // Create task from outcome + backdrop.querySelector('[data-action="create-task"]')?.addEventListener("click", async () => { + const token = getAccessToken(); + if (!token) { alert("Please sign in to create tasks."); return; } + const title = prompt("Task title:", d.label); + if (!title) return; + try { + const res = await fetch("/api/flows/outcome-tasks/create", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ space: this.space, outcomeId: nodeId, title }), + }); + const result = await res.json(); + if (result.ok && result.ref) { + if (!d.linkedTaskIds) d.linkedTaskIds = []; + d.linkedTaskIds.push(result.ref); + this.loadLinkedTasks(backdrop, nodeId); + this.drawCanvasContent(); + } + } catch {} + }); + + // Link existing task + backdrop.querySelector('[data-action="link-task"]')?.addEventListener("click", () => { + this.openTaskPicker(backdrop, nodeId); + }); + + // View in rTasks + backdrop.querySelector('[data-action="view-in-rtasks"]')?.addEventListener("click", () => { + const firstRef = d.linkedTaskIds?.[0]; + if (firstRef) { + const [boardId] = firstRef.split(":"); + window.location.href = `/${this.space}/rtasks?board=${boardId}`; + } + }); + } + + private openPipeEditor(nodeId: string, side: "left" | "right", e: PointerEvent) { + // Close any existing pipe editor + this.shadow.querySelector(".pipe-editor-popover")?.remove(); + + const node = this.nodes.find((n) => n.id === nodeId); + if (!node || node.type !== "funnel") return; + const d = node.data as FunnelNodeData; + + const allocs = d.overflowAllocations || []; + const connectedTargets = allocs.map((a) => { + const target = this.nodes.find((n) => n.id === a.targetId); + return { ...a, targetLabel: target ? (target.data as any).label || a.targetId : a.targetId }; + }); + + // Position popover near click + const svg = this.shadow.querySelector("svg") as SVGSVGElement; + if (!svg) return; + const svgRect = svg.getBoundingClientRect(); + const popX = e.clientX - svgRect.left + 10; + const popY = e.clientY - svgRect.top; + + const popover = document.createElement("div"); + popover.className = "pipe-editor-popover"; + popover.style.cssText = `position:absolute;left:${popX}px;top:${popY}px;z-index:40;background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:12px;padding:16px;min-width:220px;box-shadow:var(--rs-shadow-lg)`; + + let allocHtml = ""; + if (connectedTargets.length === 0) { + allocHtml = `
No overflow targets wired. Wire an overflow port to a funnel to configure.
`; + } else { + allocHtml = connectedTargets.map((a, i) => ` +
+
+ ${this.esc(a.targetLabel)} + + ${a.percentage}% +
+ `).join(""); + } + + popover.innerHTML = ` +
+ Overflow Allocations + +
+ ${allocHtml} + `; + + // Wrap in relative container to position correctly + const canvasContainer = svg.parentElement; + if (canvasContainer) { + canvasContainer.style.position = "relative"; + canvasContainer.appendChild(popover); + } + + // Event handlers + popover.querySelector("[data-pipe-close]")?.addEventListener("click", () => popover.remove()); + + popover.querySelectorAll('input[type="range"]').forEach((slider) => { + slider.addEventListener("input", (ev) => { + const idx = parseInt((slider as HTMLElement).dataset.allocIdx!, 10); + const val = parseInt((ev.target as HTMLInputElement).value, 10); + if (d.overflowAllocations && d.overflowAllocations[idx]) { + d.overflowAllocations[idx].percentage = val; + const pctLabel = popover.querySelector(`[data-alloc-pct="${idx}"]`); + if (pctLabel) pctLabel.textContent = `${val}%`; + this.drawCanvasContent(); + this.scheduleSave(); + } + }); + }); + + // Click outside to close + const outsideHandler = (ev: MouseEvent) => { + if (!popover.contains(ev.target as Node)) { + popover.remove(); + document.removeEventListener("pointerdown", outsideHandler, true); + } + }; + setTimeout(() => document.addEventListener("pointerdown", outsideHandler, true), 10); } private openSourceModal(nodeId: string) { diff --git a/modules/rflows/lib/types.ts b/modules/rflows/lib/types.ts index c9a3c288..64b8f3fd 100644 --- a/modules/rflows/lib/types.ts +++ b/modules/rflows/lib/types.ts @@ -84,6 +84,8 @@ export function migrateOutcomeNodeData(d: any): OutcomeNodeData { phases: d.phases, overflowAllocations: d.overflowAllocations?.map((a: any) => ({ ...a, percentage: safeNum(a.percentage) })), source: d.source, + linkedTaskIds: Array.isArray(d.linkedTaskIds) ? d.linkedTaskIds : [], + linkedBoardId: d.linkedBoardId, }; } @@ -142,6 +144,10 @@ export interface OutcomeNodeData { phases?: OutcomePhase[]; overflowAllocations?: OverflowAllocation[]; source?: IntegrationSource; + /** Array of "{boardId}:{taskId}" strings linking to rTasks items */ + linkedTaskIds?: string[]; + /** Default rTasks board to link tasks from */ + linkedBoardId?: string; [key: string]: unknown; } diff --git a/modules/rflows/mod.ts b/modules/rflows/mod.ts index ae4b6580..1701ad37 100644 --- a/modules/rflows/mod.ts +++ b/modules/rflows/mod.ts @@ -72,6 +72,23 @@ function ensureDoc(space: string): FlowsDoc { }); doc = _syncServer!.getDoc(docId)!; } + // Migrate v4 → v5: add linkedTaskIds to outcome nodes + if (doc.meta.version < 5) { + _syncServer!.changeDoc(docId, 'migrate to v5', (d) => { + if (d.canvasFlows) { + for (const flow of Object.values(d.canvasFlows)) { + if (!flow.nodes) continue; + for (const node of flow.nodes as any[]) { + if (node.type === 'outcome' && !node.data.linkedTaskIds) { + node.data.linkedTaskIds = [] as any; + } + } + } + } + d.meta.version = 5 as any; + }); + doc = _syncServer!.getDoc(docId)!; + } return doc; } @@ -781,6 +798,219 @@ routes.post("/api/budgets/segments", async (c) => { return c.json({ error: "action must be 'add' or 'remove'" }, 400); }); +// ─── Outcome-Tasks integration API ─────────────────────── + +/** Helper: find outcome node in FlowsDoc */ +function findOutcomeNode(doc: FlowsDoc, outcomeId: string): { flow: CanvasFlow; nodeIdx: number; data: OutcomeNodeData } | null { + for (const flow of Object.values(doc.canvasFlows)) { + if (!flow.nodes) continue; + for (let i = 0; i < flow.nodes.length; i++) { + const node = flow.nodes[i]; + if (node.id === outcomeId && node.type === 'outcome') { + return { flow, nodeIdx: i, data: node.data as OutcomeNodeData }; + } + } + } + return null; +} + +routes.get("/api/flows/outcome-tasks", async (c) => { + const space = c.req.query("space") || ""; + const outcomeId = c.req.query("outcomeId") || ""; + if (!space || !outcomeId) return c.json({ error: "space and outcomeId required" }, 400); + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + + const doc = ensureDoc(space); + const found = findOutcomeNode(doc, outcomeId); + if (!found) return c.json({ error: "Outcome not found" }, 404); + + const linkedTaskIds = found.data.linkedTaskIds || []; + const tasks: any[] = []; + + for (const ref of linkedTaskIds) { + const [boardId, taskId] = ref.split(":"); + if (!boardId || !taskId) continue; + const boardDoc = _syncServer.getDoc(boardDocId(space, boardId)); + if (!boardDoc?.tasks?.[taskId]) continue; + const t = boardDoc.tasks[taskId]; + tasks.push({ + ref, + boardId, + taskId, + title: t.title, + status: t.status, + priority: t.priority, + assigneeId: t.assigneeId, + labels: t.labels, + }); + } + + // Also return available boards for the task picker + const boards: { id: string; name: string; taskCount: number }[] = []; + for (const id of _syncServer.getDocIds()) { + if (!id.startsWith(`${space}:tasks:boards:`)) continue; + const bd = _syncServer.getDoc(id); + if (!bd?.board) continue; + boards.push({ id: bd.board.id, name: bd.board.name, taskCount: Object.keys(bd.tasks || {}).length }); + } + + return c.json({ tasks, boards, linkedBoardId: found.data.linkedBoardId || null }); +}); + +routes.post("/api/flows/outcome-tasks/link", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + + const { space, outcomeId, boardId, taskId } = await c.req.json(); + if (!space || !outcomeId || !boardId || !taskId) return c.json({ error: "space, outcomeId, boardId, taskId required" }, 400); + + const doc = ensureDoc(space); + const found = findOutcomeNode(doc, outcomeId); + if (!found) return c.json({ error: "Outcome not found" }, 404); + + // Verify task exists + const boardDoc = _syncServer.getDoc(boardDocId(space, boardId)); + if (!boardDoc?.tasks?.[taskId]) return c.json({ error: "Task not found" }, 404); + + const ref = `${boardId}:${taskId}`; + const docId = flowsDocId(space); + + _syncServer.changeDoc(docId, `link task ${ref} to outcome ${outcomeId}`, (d) => { + for (const flow of Object.values(d.canvasFlows)) { + if (!flow.nodes) continue; + for (const node of flow.nodes) { + if (node.id === outcomeId && node.type === 'outcome') { + const data = node.data as any; + if (!data.linkedTaskIds) data.linkedTaskIds = []; + if (!data.linkedTaskIds.includes(ref)) data.linkedTaskIds.push(ref); + } + } + } + }); + + return c.json({ ok: true, ref }); +}); + +routes.post("/api/flows/outcome-tasks/unlink", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + + const { space, outcomeId, ref } = await c.req.json(); + if (!space || !outcomeId || !ref) return c.json({ error: "space, outcomeId, ref required" }, 400); + + const docId = flowsDocId(space); + _syncServer.changeDoc(docId, `unlink task ${ref} from outcome ${outcomeId}`, (d) => { + for (const flow of Object.values(d.canvasFlows)) { + if (!flow.nodes) continue; + for (const node of flow.nodes) { + if (node.id === outcomeId && node.type === 'outcome') { + const data = node.data as any; + if (data.linkedTaskIds) { + const idx = data.linkedTaskIds.indexOf(ref); + if (idx >= 0) data.linkedTaskIds.splice(idx, 1); + } + } + } + } + }); + + return c.json({ ok: true }); +}); + +routes.post("/api/flows/outcome-tasks/create", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + + const { space, outcomeId, title, boardId: reqBoardId } = await c.req.json(); + if (!space || !outcomeId) return c.json({ error: "space, outcomeId required" }, 400); + + const doc = ensureDoc(space); + const found = findOutcomeNode(doc, outcomeId); + if (!found) return c.json({ error: "Outcome not found" }, 404); + + // Use requested board or linked board or default BCRG board + const boardId = reqBoardId || found.data.linkedBoardId || `${space}-bcrg`; + const bDocId = boardDocId(space, boardId); + const boardDoc = _syncServer.getDoc(bDocId); + if (!boardDoc) return c.json({ error: "Board not found" }, 404); + + const taskId = crypto.randomUUID(); + const taskTitle = title || `Task: ${found.data.label}`; + const refTag = `ref:rflows:outcome:${outcomeId}`; + const did = (claims as any).did || claims.sub; + + _syncServer.changeDoc(bDocId, `Create task for outcome ${outcomeId}`, (d) => { + d.tasks[taskId] = createTaskItem(taskId, space, taskTitle, { + status: 'TODO', + priority: 'MEDIUM', + description: `${refTag} — Created from rFlows outcome "${found!.data.label}"`, + labels: ['rflows'], + createdBy: did, + }); + }); + + // Auto-link the new task to the outcome + const ref = `${boardId}:${taskId}`; + const fDocId = flowsDocId(space); + _syncServer.changeDoc(fDocId, `auto-link created task to outcome ${outcomeId}`, (d) => { + for (const flow of Object.values(d.canvasFlows)) { + if (!flow.nodes) continue; + for (const node of flow.nodes) { + if (node.id === outcomeId && node.type === 'outcome') { + const data = node.data as any; + if (!data.linkedTaskIds) data.linkedTaskIds = []; + if (!data.linkedTaskIds.includes(ref)) data.linkedTaskIds.push(ref); + } + } + } + }); + + console.log(`[rflows] Created task "${taskTitle}" in board ${boardId}, linked to outcome ${outcomeId}`); + return c.json({ ok: true, ref, taskId, boardId }); +}); + +// List unlinked tasks from a board (for picker) +routes.get("/api/flows/board-tasks", async (c) => { + const space = c.req.query("space") || ""; + const boardId = c.req.query("boardId") || ""; + const outcomeId = c.req.query("outcomeId") || ""; + if (!space || !boardId) return c.json({ error: "space and boardId required" }, 400); + if (!_syncServer) return c.json({ error: "Not initialized" }, 503); + + const boardDoc = _syncServer.getDoc(boardDocId(space, boardId)); + if (!boardDoc) return c.json({ tasks: [] }); + + // Get already-linked task IDs for this outcome + const linkedRefs = new Set(); + if (outcomeId) { + const doc = ensureDoc(space); + const found = findOutcomeNode(doc, outcomeId); + if (found?.data.linkedTaskIds) { + for (const ref of found.data.linkedTaskIds) linkedRefs.add(ref); + } + } + + const tasks = Object.values(boardDoc.tasks) + .filter((t) => !linkedRefs.has(`${boardId}:${t.id}`)) + .map((t) => ({ + id: t.id, + title: t.title, + status: t.status, + priority: t.priority, + labels: t.labels, + })); + + return c.json({ tasks }); +}); + // ─── Page routes ──────────────────────────────────────── const flowsScripts = ` @@ -1039,6 +1269,77 @@ export const flowsModule: RSpaceModule = { } catch {} }); + // Reverse sync: rTasks status changes → update linked outcome status + let _syncInProgress = false; + _syncServer.registerWatcher(':tasks:boards:', (docId, doc) => { + if (_syncInProgress) return; + try { + const boardDoc = doc as BoardDoc; + if (!boardDoc?.tasks || !boardDoc?.board) return; + + // Extract space from docId: {space}:tasks:boards:{boardId} + const parts = docId.split(':tasks:boards:'); + const space = parts[0]; + const boardId = parts[1]; + if (!space || !boardId) return; + + // Find flow docs for this space + const fDocId = flowsDocId(space); + const flowsDoc = _syncServer!.getDoc(fDocId); + if (!flowsDoc?.canvasFlows) return; + + // Check each outcome's linked tasks + for (const flow of Object.values(flowsDoc.canvasFlows)) { + if (!flow.nodes) continue; + for (const node of flow.nodes) { + if (node.type !== 'outcome') continue; + const data = node.data as OutcomeNodeData; + if (!data.linkedTaskIds || data.linkedTaskIds.length === 0) continue; + + // Filter linked tasks that belong to this board + const boardRefs = data.linkedTaskIds.filter((r) => r.startsWith(`${boardId}:`)); + if (boardRefs.length === 0) continue; + + // Check statuses of ALL linked tasks (across all boards) + const allStatuses: string[] = []; + for (const ref of data.linkedTaskIds) { + const [bid, tid] = ref.split(':'); + const bd = bid === boardId ? boardDoc : _syncServer!.getDoc(boardDocId(space, bid)); + if (bd?.tasks?.[tid]) allStatuses.push(bd.tasks[tid].status); + } + + if (allStatuses.length === 0) continue; + + const allDone = allStatuses.every((s) => s === 'DONE'); + const anyInProgress = allStatuses.some((s) => s === 'IN_PROGRESS'); + + let newStatus: OutcomeNodeData['status'] | null = null; + if (allDone && data.status !== 'completed') { + newStatus = 'completed'; + } else if (anyInProgress && data.status === 'not-started') { + newStatus = 'in-progress'; + } + + if (newStatus) { + _syncInProgress = true; + _syncServer!.changeDoc(fDocId, `sync outcome ${node.id} → ${newStatus}`, (d) => { + for (const f of Object.values(d.canvasFlows)) { + if (!f.nodes) continue; + for (const n of f.nodes) { + if (n.id === node.id && n.type === 'outcome') { + (n.data as any).status = newStatus; + } + } + } + }); + _syncInProgress = false; + console.log(`[rflows] Reverse sync: outcome "${node.id}" → ${newStatus}`); + } + } + } + } catch {} + }); + // Pre-populate _completedOutcomes from existing docs to avoid duplicates on restart for (const id of _syncServer.getDocIds()) { if (!id.includes(':flows:data')) continue; diff --git a/modules/rflows/schemas.ts b/modules/rflows/schemas.ts index f332062b..d235153a 100644 --- a/modules/rflows/schemas.ts +++ b/modules/rflows/schemas.ts @@ -8,6 +8,7 @@ * v2: adds canvasFlows (full node data) and activeFlowId * v3: adds mortgagePositions and reinvestmentPositions * v4: adds budgetSegments, budgetAllocations, budgetTotalAmount + * v5: adds linkedTaskIds and linkedBoardId to OutcomeNodeData */ import type { DocSchema } from '../../shared/local-first/document'; @@ -55,12 +56,12 @@ export interface FlowsDoc { export const flowsSchema: DocSchema = { module: 'flows', collection: 'data', - version: 4, + version: 5, init: (): FlowsDoc => ({ meta: { module: 'flows', collection: 'data', - version: 4, + version: 5, spaceSlug: '', createdAt: Date.now(), }, @@ -81,7 +82,18 @@ export const flowsSchema: DocSchema = { if (!doc.budgetSegments) doc.budgetSegments = {}; if (!doc.budgetAllocations) doc.budgetAllocations = {}; if (doc.budgetTotalAmount === undefined) doc.budgetTotalAmount = 0; - doc.meta.version = 4; + // v5: migrate outcome nodes — add linkedTaskIds/linkedBoardId + if (doc.canvasFlows) { + for (const flow of Object.values(doc.canvasFlows) as any[]) { + if (!flow.nodes) continue; + for (const node of flow.nodes as any[]) { + if (node.type === 'outcome' && !node.data.linkedTaskIds) { + node.data.linkedTaskIds = []; + } + } + } + } + doc.meta.version = 5; return doc; }, };