@@ -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 = `
` : ""}
+
+
+
+
+ Linked Tasks ${linkedCount > 0 ? `(${linkedCount})` : ""}
+
+
+
+
+
+
+
+
+
+ ${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
+
+
+
+
+
+
+
`;
+
+ 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;
},
};