feat(rflows): outcome→rTasks integration + overflow pipe interactivity

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-14 16:41:18 -04:00
parent dc5dfcccc8
commit 240131ae70
5 changed files with 722 additions and 13 deletions

View File

@ -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;
}

View File

@ -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}
<!-- Overflow pipes at max threshold -->
<rect class="funnel-pipe ${isOverflow ? "funnel-pipe--active" : ""}" data-pipe="left" data-node-id="${n.id}" x="${-pipeW}" y="${pipeY}" width="${pipeW}" height="${pipeH}" rx="4" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.85 : 0.3}"/>
${isOverflow ? `<line class="pipe-flow-stripe" x1="${-pipeW + 2}" y1="${pipeY + pipeH / 2}" x2="${-2}" y2="${pipeY + pipeH / 2}" stroke="rgba(255,255,255,0.3)" stroke-width="2" stroke-dasharray="4 4"/>` : ""}
<rect class="funnel-pipe ${isOverflow ? "funnel-pipe--active" : ""}" data-pipe="right" data-node-id="${n.id}" x="${w}" y="${pipeY}" width="${pipeW}" height="${pipeH}" rx="4" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.85 : 0.3}"/>
${isOverflow ? `<line class="pipe-flow-stripe" x1="${w + 2}" y1="${pipeY + pipeH / 2}" x2="${w + pipeW - 2}" y2="${pipeY + pipeH / 2}" stroke="rgba(255,255,255,0.3)" stroke-width="2" stroke-dasharray="4 4"/>` : ""}
${overflowSpill}
<rect x="24" y="${satBarY}" width="${satBarW}" height="8" rx="4" style="fill:var(--rs-bg-surface-raised)" opacity="0.3" class="satisfaction-bar-bg"/>
<rect x="24" y="${satBarY}" width="${satFillW}" height="8" rx="4" style="fill:var(--rflows-sat-bar)" class="satisfaction-bar-fill" ${satBarBorder}/>
@ -2293,7 +2315,8 @@ class FolkFlowsApp extends HTMLElement {
</g>
${overflowSplash}
<!-- Overflow pipe stub below basin (when overfunded) -->
${oOverflowPipeW > 0 ? `<rect x="${(w - oOverflowPipeW) / 2}" y="${h}" width="${oOverflowPipeW}" height="18" rx="${Math.min(4, oOverflowPipeW / 2)}" fill="var(--rflows-status-overflow)" opacity="0.6"/>` : ""}
${oOverflowPipeW > 0 ? `<rect class="funnel-pipe funnel-pipe--active" data-pipe="bottom" data-node-id="${n.id}" x="${(w - oOverflowPipeW) / 2}" y="${h}" width="${oOverflowPipeW}" height="18" rx="${Math.min(4, oOverflowPipeW / 2)}" fill="var(--rflows-status-overflow)" opacity="0.6"/>
<line class="pipe-flow-stripe" x1="${w / 2}" y1="${h + 2}" x2="${w / 2}" y2="${h + 16}" stroke="rgba(255,255,255,0.3)" stroke-width="2" stroke-dasharray="4 4"/>` : ""}
<!-- Header above basin -->
<foreignObject x="-10" y="-32" width="${w + 20}" height="34">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:center;justify-content:center;gap:6px;font-family:system-ui,-apple-system,sans-serif;pointer-events:none">
@ -2305,6 +2328,11 @@ class FolkFlowsApp extends HTMLElement {
<!-- Funding text centered in basin -->
<text x="${w / 2}" y="${Math.max(waterTop + (h - waterTop) / 2 + 4, h / 2 + 4)}" text-anchor="middle" fill="var(--rs-text-primary)" font-size="12" font-weight="600" font-family="ui-monospace,monospace" pointer-events="none" opacity="0.9">${Math.round(fillPct * 100)}%</text>
<text x="${w / 2}" y="${Math.max(waterTop + (h - waterTop) / 2 + 18, h / 2 + 18)}" text-anchor="middle" fill="var(--rs-text-muted)" font-size="10" pointer-events="none">${dollarLabel}</text>
${(d.linkedTaskIds?.length || 0) > 0 ? `<!-- rTasks linked badge -->
<g class="outcome-tasks-badge" transform="translate(${w - 8}, -8)">
<circle cx="0" cy="0" r="10" fill="var(--rflows-status-inprogress)" opacity="0.9"/>
<text x="0" y="4" text-anchor="middle" fill="white" font-size="9" font-weight="700">${d.linkedTaskIds!.length}</text>
</g>` : ""}
${this.renderPortsSvg(n)}
</g>`;
}
@ -4697,14 +4725,16 @@ class FolkFlowsApp extends HTMLElement {
phasesHtml += `<button class="phase-add-btn" data-action="add-phase" style="width:100%;justify-content:center;padding:8px">+ Add Phase</button>`;
}
const linkedCount = d.linkedTaskIds?.length || 0;
const backdrop = document.createElement("div");
backdrop.className = "flows-modal-backdrop";
backdrop.id = "flows-modal";
backdrop.innerHTML = `<div class="flows-modal">
backdrop.innerHTML = `<div class="flows-modal" style="width:500px">
<div class="flows-modal__header">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;background:${statusColor}22;color:${statusColor}">${statusLabel}</span>
<span style="font-size:16px;font-weight:700;color:var(--rs-text-primary)">${this.esc(d.label)}</span>
<div style="display:flex;align-items:center;gap:10px;flex:1;min-width:0">
<span style="display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;background:${statusColor}22;color:${statusColor};flex-shrink:0">${statusLabel}</span>
<span style="font-size:16px;font-weight:700;color:var(--rs-text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${this.esc(d.label)}</span>
</div>
<button class="flows-modal__close" data-modal-action="close">&times;</button>
</div>
@ -4720,10 +4750,195 @@ class FolkFlowsApp extends HTMLElement {
<div style="font-size:13px;font-weight:600;color:var(--rs-text-secondary);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:10px">Phases</div>
${phasesHtml}
</div>` : ""}
<!-- Linked Tasks section -->
<div style="margin-bottom:16px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<div style="font-size:13px;font-weight:600;color:var(--rs-text-secondary);text-transform:uppercase;letter-spacing:0.05em">
Linked Tasks ${linkedCount > 0 ? `<span style="font-size:11px;color:var(--rs-text-muted);font-weight:400">(${linkedCount})</span>` : ""}
</div>
<div style="display:flex;gap:6px">
<button class="phase-add-btn" data-action="create-task" style="margin:0">+ Create Task</button>
<button class="phase-add-btn" data-action="link-task" style="margin:0">+ Link Existing</button>
</div>
</div>
<div class="outcome-linked-tasks" data-loading="true">
<div style="text-align:center;padding:16px;color:var(--rs-text-muted);font-size:12px">Loading tasks...</div>
</div>
</div>
<!-- Footer -->
${linkedCount > 0 ? `<div style="border-top:1px solid var(--rs-border-strong);padding-top:12px;text-align:center">
<a data-action="view-in-rtasks" style="font-size:13px;color:var(--rflows-status-inprogress);cursor:pointer;text-decoration:none;font-weight:500">View in rTasks &rarr;</a>
</div>` : ""}
</div>`;
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 = `<div style="text-align:center;padding:16px;color:var(--rs-text-muted);font-size:12px;border:1px dashed var(--rs-border-strong);border-radius:8px">No linked tasks yet. Link existing tasks or create new ones.</div>`;
return;
}
const statusColors: Record<string, { bg: string; fg: string }> = {
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' ? '<span style="color:#ef4444">!</span>' : t.priority === 'MEDIUM' ? '<span style="color:#f59e0b">!</span>' : '';
return `<div class="linked-task-row" data-ref="${t.ref}">
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
${priorityIcon}
<span class="linked-task-title" data-board="${t.boardId}" data-task="${t.taskId}">${this.esc(t.title)}</span>
<span style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:9999px;background:${sc.bg};color:${sc.fg};flex-shrink:0">${t.status.replace("_", " ")}</span>
</div>
<button class="linked-task-unlink" data-unlink-ref="${t.ref}" title="Unlink">&times;</button>
</div>`;
}).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 = `<div style="text-align:center;padding:16px;color:var(--rs-text-muted);font-size:12px">Could not load linked tasks</div>`;
}
}
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 = `<div class="task-picker-panel">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<span style="font-size:13px;font-weight:600;color:var(--rs-text-primary)">Link a Task</span>
<button class="flows-modal__close" data-picker-close>&times;</button>
</div>
<div style="margin-bottom:10px">
<select class="editor-select" data-picker-board style="width:100%;padding:6px 10px;font-size:12px">
${boards.map((b) => `<option value="${b.id}" ${b.id === defaultBoard ? "selected" : ""}>${this.esc(b.name)}</option>`).join("")}
</select>
</div>
<div class="task-picker-list" style="max-height:240px;overflow-y:auto">
<div style="text-align:center;padding:16px;color:var(--rs-text-muted);font-size:12px">Loading...</div>
</div>
</div>`;
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 = `<div style="text-align:center;padding:16px;color:var(--rs-text-muted);font-size:12px">No unlinked tasks in this board</div>`;
return;
}
const statusColors: Record<string, string> = { TODO: "#94a3b8", IN_PROGRESS: "#3b82f6", DONE: "#10b981" };
listEl.innerHTML = data.tasks.map((t: any) => `
<div class="task-picker-item" data-task-id="${t.id}" data-board-id="${boardId}">
<span style="flex:1;font-size:12px;color:var(--rs-text-primary)">${this.esc(t.title)}</span>
<span style="font-size:10px;color:${statusColors[t.status] || "#94a3b8"}">${t.status.replace("_", " ")}</span>
</div>
`).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 = `<div style="text-align:center;padding:16px;color:var(--rs-text-muted);font-size:12px">Failed to load tasks</div>`;
}
};
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 = `<div style="text-align:center;padding:12px;color:var(--rs-text-muted);font-size:12px">No overflow targets wired. Wire an overflow port to a funnel to configure.</div>`;
} else {
allocHtml = connectedTargets.map((a, i) => `
<div class="pipe-alloc-row" style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<div style="width:8px;height:8px;border-radius:50%;background:${a.color};flex-shrink:0"></div>
<span style="flex:1;font-size:12px;color:var(--rs-text-primary)">${this.esc(a.targetLabel)}</span>
<input type="range" min="0" max="100" value="${a.percentage}" data-alloc-idx="${i}" style="width:80px;accent-color:${a.color}"/>
<span class="pipe-alloc-pct" data-alloc-pct="${i}" style="font-size:11px;color:var(--rs-text-muted);width:32px;text-align:right">${a.percentage}%</span>
</div>
`).join("");
}
popover.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<span style="font-size:13px;font-weight:600;color:var(--rs-text-primary)">Overflow Allocations</span>
<button class="flows-modal__close" data-pipe-close style="font-size:16px;padding:0 4px">&times;</button>
</div>
${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) {

View File

@ -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;
}

View File

@ -72,6 +72,23 @@ function ensureDoc(space: string): FlowsDoc {
});
doc = _syncServer!.getDoc<FlowsDoc>(docId)!;
}
// Migrate v4 → v5: add linkedTaskIds to outcome nodes
if (doc.meta.version < 5) {
_syncServer!.changeDoc<FlowsDoc>(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<FlowsDoc>(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<BoardDoc>(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<BoardDoc>(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<BoardDoc>(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<FlowsDoc>(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<FlowsDoc>(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<BoardDoc>(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<BoardDoc>(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<FlowsDoc>(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<BoardDoc>(boardDocId(space, boardId));
if (!boardDoc) return c.json({ tasks: [] });
// Get already-linked task IDs for this outcome
const linkedRefs = new Set<string>();
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<FlowsDoc>(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<BoardDoc>(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<FlowsDoc>(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;

View File

@ -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<FlowsDoc> = {
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<FlowsDoc> = {
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;
},
};