From 246b51b2e0c5a840b7188a4ca028ce65d0ecbb31 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 15 Mar 2026 17:04:02 -0700 Subject: [PATCH] feat(rbudgets): multiplayer sync, interactive pie chart, flow integration - Add budget CRUD methods to FlowsLocalFirstClient (saveBudgetAllocation, addBudgetSegment, removeBudgetSegment, setBudgetTotalAmount) - Init local-first client in budget view with real-time onChange sync - extractBudgetState() recomputes collective averages from Automerge doc - Debounced auto-save (1s) via scheduleBudgetSave() on slider/pie changes - Interactive pie chart: click wedges to select, drag boundaries between segments to adjust allocation percentages with angle-to-pct geometry - Selected segment highlighting (scaled wedge, white border, detail panel, slider row highlight, legend/table row click-to-select) - "Apply to Flow" button pushes collective budget into canvas flow as funnel node with spending allocations mapped to outcome nodes - LIVE indicator when WebSocket connected - Falls back to API for demo/unauthenticated users Co-Authored-By: Claude Opus 4.6 --- modules/rflows/components/folk-flows-app.ts | 424 +++++++++++++++++++- modules/rflows/local-first-client.ts | 48 +++ 2 files changed, 457 insertions(+), 15 deletions(-) diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index c88375f..a10e226 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -185,6 +185,15 @@ class FolkFlowsApp extends HTMLElement { private collectiveAllocations: { segmentId: string; avgPercentage: number; participantCount: number }[] = []; private budgetTotalAmount = 0; private budgetParticipantCount = 0; + private selectedBudgetSegment: string | null = null; + private _budgetLfcUnsub: (() => void) | null = null; + private budgetSaveTimer: ReturnType | null = null; + + // Pie drag state + private _pieDragging = false; + private _pieDragBoundaryIdx = 0; + private _pieDragStartAngle = 0; + private _pieDragStartPcts: number[] = []; // Tour engine private _tour!: TourEngine; @@ -223,7 +232,7 @@ class FolkFlowsApp extends HTMLElement { this.view = viewAttr === "budgets" ? "budgets" : viewAttr === "mortgage" ? "mortgage" : "detail"; if (this.view === "budgets") { - this.loadBudgetData(); + this.initBudgetView(); return; } @@ -351,7 +360,10 @@ class FolkFlowsApp extends HTMLElement { this._offlineUnsub = null; this._lfcUnsub?.(); this._lfcUnsub = null; + this._budgetLfcUnsub?.(); + this._budgetLfcUnsub = null; if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; } + if (this.budgetSaveTimer) { clearTimeout(this.budgetSaveTimer); this.budgetSaveTimer = null; } this.localFirstClient?.disconnect(); } @@ -5496,6 +5508,7 @@ class FolkFlowsApp extends HTMLElement { const segId = (slider as HTMLElement).dataset.budgetSlider!; this.myAllocation[segId] = parseInt((e.target as HTMLInputElement).value) || 0; this.normalizeBudgetSliders(segId); + this.scheduleBudgetSave(); this.render(); }); }); @@ -5517,6 +5530,23 @@ class FolkFlowsApp extends HTMLElement { } }); }); + + this.shadow.querySelector('[data-budget-action="apply-flow"]')?.addEventListener('click', () => { + this.applyBudgetToFlow(); + }); + + // Legend/table row click to select segment + this.shadow.querySelectorAll('[data-pie-legend]').forEach((el) => { + el.addEventListener('click', (e) => { + // Don't intercept remove button clicks + if ((e.target as HTMLElement).dataset.budgetRemove) return; + const segId = (el as HTMLElement).dataset.pieLegend!; + this.selectedBudgetSegment = this.selectedBudgetSegment === segId ? null : segId; + this.render(); + }); + }); + + this.attachPieListeners(); } } @@ -5587,6 +5617,205 @@ class FolkFlowsApp extends HTMLElement { // ─── Budget tab ───────────────────────────────────────── + private async initBudgetView() { + if (!this.isDemo && isAuthenticated()) { + // Multiplayer: init local-first client and sync budget state + try { + this.localFirstClient = new FlowsLocalFirstClient(this.space); + await this.localFirstClient.init(); + await this.localFirstClient.subscribe(); + + this._budgetLfcUnsub = this.localFirstClient.onChange((doc) => { + this.extractBudgetState(doc); + this.render(); + }); + + // Initial extraction from doc + const doc = this.localFirstClient.getFlows(); + if (doc) this.extractBudgetState(doc); + } catch (err) { + console.warn('[rBudgets] Local-first init failed, falling back to API:', err); + } + } + + // Fall back to API / demo data if no segments loaded from local-first + if (this.budgetSegments.length === 0) { + await this.loadBudgetData(); + } else { + this.loading = false; + this.render(); + } + } + + private extractBudgetState(doc: FlowsDoc) { + // Extract segments + const segments: BudgetSegment[] = []; + if (doc.budgetSegments) { + for (const [id, seg] of Object.entries(doc.budgetSegments)) { + segments.push({ id, name: seg.name, color: seg.color, createdBy: seg.createdBy }); + } + } + if (segments.length > 0) this.budgetSegments = segments; + + // Extract total amount + if (doc.budgetTotalAmount) this.budgetTotalAmount = doc.budgetTotalAmount; + + // Extract allocations and compute collective averages + if (doc.budgetAllocations) { + const allAllocations = Object.values(doc.budgetAllocations); + this.budgetParticipantCount = allAllocations.length; + + // My allocation + const session = getSession(); + if (session) { + const myDid = (session.claims as any).did || session.claims.sub; + const myAlloc = allAllocations.find(a => a.participantDid === myDid); + if (myAlloc && !this._pieDragging && !this.budgetSaveTimer) { + // Only update from remote if not currently editing + this.myAllocation = { ...myAlloc.allocations }; + } + } + + // Collective averages + const segIds = this.budgetSegments.map(s => s.id); + this.collectiveAllocations = segIds.map(segId => { + const values = allAllocations + .map(a => a.allocations[segId] || 0) + .filter(v => v > 0 || allAllocations.length > 0); + const count = allAllocations.length; + const avg = count > 0 ? values.reduce((s, v) => s + v, 0) / count : 0; + return { segmentId: segId, avgPercentage: avg, participantCount: count }; + }); + } + } + + private scheduleBudgetSave() { + if (this.budgetSaveTimer) clearTimeout(this.budgetSaveTimer); + this.budgetSaveTimer = setTimeout(() => { + this.budgetSaveTimer = null; + if (this.localFirstClient) { + const session = getSession(); + if (session) { + const myDid = (session.claims as any).did || session.claims.sub; + this.localFirstClient.saveBudgetAllocation(myDid, this.myAllocation); + } + } else { + // Fallback: save via API + this.saveBudgetAllocation(); + } + }, 1000); + } + + private applyBudgetToFlow() { + if (!this.localFirstClient) { + alert('Connect to a space to apply budgets to a flow.'); + return; + } + + const collectiveData = this.collectiveAllocations.filter(c => c.avgPercentage > 0); + if (collectiveData.length === 0) { + alert('No collective allocation data to apply.'); + return; + } + + // Get or create active canvas flow + let activeId = this.localFirstClient.getActiveFlowId(); + const flows = this.localFirstClient.listCanvasFlows(); + + if (!activeId && flows.length > 0) { + activeId = flows[0].id; + } + + if (!activeId) { + // Create a new flow for budget + const flowId = 'budget-flow-' + Date.now(); + const newFlow: CanvasFlow = { + id: flowId, + name: 'Budget Allocation Flow', + nodes: [], + createdAt: Date.now(), + updatedAt: Date.now(), + createdBy: getSession()?.claims?.sub || null, + }; + this.localFirstClient.saveCanvasFlow(newFlow); + this.localFirstClient.setActiveFlow(flowId); + activeId = flowId; + } + + const flow = this.localFirstClient.getCanvasFlow(activeId); + if (!flow) return; + + const nodes: FlowNode[] = [...flow.nodes.map(n => ({ ...n, data: { ...n.data } }))]; + + // Find or create funnel node for budget pool + let funnelNode = nodes.find(n => n.type === 'funnel' && (n.data as FunnelNodeData).label.includes('Budget')); + if (!funnelNode) { + funnelNode = { + id: 'budget-funnel-' + Date.now(), + type: 'funnel', + position: { x: 400, y: 100 }, + data: { + label: 'Budget Pool', + currentValue: this.budgetTotalAmount, + minThreshold: this.budgetTotalAmount * 0.2, + maxThreshold: this.budgetTotalAmount * 0.8, + maxCapacity: this.budgetTotalAmount, + inflowRate: 0, + overflowAllocations: [], + spendingAllocations: [], + } as FunnelNodeData, + }; + nodes.push(funnelNode); + } + + const funnelData = funnelNode.data as FunnelNodeData; + const colors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#ec4899', '#14b8a6', '#f97316']; + + // Create/update outcome nodes for each segment + build spending allocations + const spendingAllocations: SpendingAllocation[] = []; + let outcomeX = 200; + + for (let i = 0; i < collectiveData.length; i++) { + const c = collectiveData[i]; + const seg = this.budgetSegments.find(s => s.id === c.segmentId); + if (!seg) continue; + + // Find or create outcome node + let outcomeNode = nodes.find(n => n.type === 'outcome' && (n.data as OutcomeNodeData).label === seg.name); + if (!outcomeNode) { + outcomeNode = { + id: 'budget-outcome-' + seg.id, + type: 'outcome', + position: { x: outcomeX, y: 350 }, + data: { + label: seg.name, + description: `Budget allocation for ${seg.name}`, + fundingReceived: 0, + fundingTarget: Math.round(this.budgetTotalAmount * c.avgPercentage / 100), + status: 'not-started', + } as OutcomeNodeData, + }; + nodes.push(outcomeNode); + } else { + // Update target + (outcomeNode.data as OutcomeNodeData).fundingTarget = Math.round(this.budgetTotalAmount * c.avgPercentage / 100); + } + + spendingAllocations.push({ + targetId: outcomeNode.id, + percentage: c.avgPercentage, + color: seg.color || colors[i % colors.length], + }); + + outcomeX += 200; + } + + funnelData.spendingAllocations = spendingAllocations; + this.localFirstClient.updateFlowNodes(activeId, nodes); + + alert(`Applied ${collectiveData.length} budget segments to flow "${flow.name}". Switch to canvas view to see the result.`); + } + private async loadBudgetData() { this.loading = true; this.render(); @@ -5650,6 +5879,13 @@ class FolkFlowsApp extends HTMLElement { const session = getSession(); if (!session) return; + if (this.localFirstClient) { + const myDid = (session.claims as any).did || session.claims.sub; + this.localFirstClient.saveBudgetAllocation(myDid, this.myAllocation); + return; + } + + // API fallback const base = this.getApiBase(); try { await fetch(`${base}/api/budgets/allocate`, { @@ -5657,7 +5893,6 @@ class FolkFlowsApp extends HTMLElement { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` }, body: JSON.stringify({ space: this.space, allocations: this.myAllocation }), }); - // Reload to get updated collective await this.loadBudgetData(); } catch (err) { console.error('[rBudgets] Save failed:', err); @@ -5665,13 +5900,21 @@ class FolkFlowsApp extends HTMLElement { } private async addBudgetSegment() { - const session = getSession(); const name = prompt('Segment name:'); if (!name) return; const colors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#ec4899', '#14b8a6', '#f97316']; const color = colors[this.budgetSegments.length % colors.length]; + const id = 'seg-' + Date.now(); + if (this.localFirstClient) { + const session = getSession(); + this.localFirstClient.addBudgetSegment(id, name, color, session ? ((session.claims as any).did || session.claims.sub) : null); + this.myAllocation[id] = 0; + return; // onChange will re-render + } + + const session = getSession(); if (session) { const base = this.getApiBase(); try { @@ -5686,15 +5929,21 @@ class FolkFlowsApp extends HTMLElement { } // Local-only fallback for demo - const id = 'seg-' + Date.now(); this.budgetSegments.push({ id, name, color, createdBy: null }); this.myAllocation[id] = 0; this.render(); } private async removeBudgetSegment(segmentId: string) { - const session = getSession(); + if (this.localFirstClient) { + this.localFirstClient.removeBudgetSegment(segmentId); + delete this.myAllocation[segmentId]; + if (this.selectedBudgetSegment === segmentId) this.selectedBudgetSegment = null; + this.normalizeBudgetSliders(); + return; // onChange will re-render + } + const session = getSession(); if (session) { const base = this.getApiBase(); try { @@ -5712,6 +5961,7 @@ class FolkFlowsApp extends HTMLElement { this.budgetSegments = this.budgetSegments.filter((s) => s.id !== segmentId); delete this.myAllocation[segmentId]; this.collectiveAllocations = this.collectiveAllocations.filter((c) => c.segmentId !== segmentId); + if (this.selectedBudgetSegment === segmentId) this.selectedBudgetSegment = null; this.normalizeBudgetSliders(); this.render(); } @@ -5764,6 +6014,108 @@ class FolkFlowsApp extends HTMLElement { } } + private attachPieListeners() { + const svg = this.shadow.getElementById('budget-pie-svg') as SVGSVGElement | null; + if (!svg) return; + + const size = svg.viewBox.baseVal.width; + const cx = size / 2, cy = size / 2; + + // Click on wedge to select + svg.addEventListener('click', (e) => { + const target = e.target as SVGElement; + const segId = target.getAttribute('data-pie-segment'); + if (segId) { + this.selectedBudgetSegment = this.selectedBudgetSegment === segId ? null : segId; + this.render(); + // Scroll slider into view + if (this.selectedBudgetSegment) { + const slider = this.shadow.querySelector(`[data-budget-slider="${this.selectedBudgetSegment}"]`) as HTMLElement; + slider?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + } + }); + + // Drag on boundary to resize adjacent segments + svg.addEventListener('pointerdown', (e) => { + const target = e.target as SVGElement; + const boundaryIdx = target.getAttribute('data-pie-boundary'); + if (boundaryIdx === null) return; + + e.preventDefault(); + (target as Element).setPointerCapture(e.pointerId); + + this._pieDragging = true; + this._pieDragBoundaryIdx = parseInt(boundaryIdx); + + // Store starting angle from pointer position + const rect = svg.getBoundingClientRect(); + const px = (e.clientX - rect.left) * (size / rect.width); + const py = (e.clientY - rect.top) * (size / rect.height); + this._pieDragStartAngle = Math.atan2(py - cy, px - cx); + + // Store starting percentages for all segments + const segIds = this.budgetSegments.map(s => s.id); + this._pieDragStartPcts = segIds.map(id => this.myAllocation[id] || 0); + + svg.style.cursor = 'grabbing'; + }); + + svg.addEventListener('pointermove', (e) => { + if (!this._pieDragging) return; + e.preventDefault(); + + const rect = svg.getBoundingClientRect(); + const px = (e.clientX - rect.left) * (size / rect.width); + const py = (e.clientY - rect.top) * (size / rect.height); + const currentAngle = Math.atan2(py - cy, px - cx); + + let deltaAngle = currentAngle - this._pieDragStartAngle; + // Normalize to [-PI, PI] + if (deltaAngle > Math.PI) deltaAngle -= 2 * Math.PI; + if (deltaAngle < -Math.PI) deltaAngle += 2 * Math.PI; + + const deltaPct = (deltaAngle / (2 * Math.PI)) * 100; + + const idx = this._pieDragBoundaryIdx; + const nextIdx = (idx + 1) % this.budgetSegments.length; + const segIds = this.budgetSegments.map(s => s.id); + + let newA = this._pieDragStartPcts[idx] + deltaPct; + let newB = this._pieDragStartPcts[nextIdx] - deltaPct; + + // Clamp both to [2, 98] + if (newA < 2) { newB += (newA - 2); newA = 2; } + if (newB < 2) { newA += (newB - 2); newB = 2; } + if (newA > 98) { newB -= (newA - 98); newA = 98; } + if (newB > 98) { newA -= (newB - 98); newB = 98; } + + this.myAllocation[segIds[idx]] = Math.round(newA); + this.myAllocation[segIds[nextIdx]] = Math.round(newB); + + // Normalize total to 100 + this.normalizeBudgetSliders(); + + // Re-render pie chart in-place for responsiveness + this.render(); + }); + + svg.addEventListener('pointerup', () => { + if (!this._pieDragging) return; + this._pieDragging = false; + svg.style.cursor = ''; + this.scheduleBudgetSave(); + }); + + svg.addEventListener('lostpointercapture', () => { + if (this._pieDragging) { + this._pieDragging = false; + svg.style.cursor = ''; + this.scheduleBudgetSave(); + } + }); + } + private renderPieChart(data: { id: string; label: string; value: number; color: string }[], size: number): string { const total = data.reduce((s, d) => s + d.value, 0); if (total === 0 || data.length === 0) { @@ -5777,8 +6129,11 @@ class FolkFlowsApp extends HTMLElement { let currentAngle = -Math.PI / 2; // Start at top const paths: string[] = []; const labels: string[] = []; + const boundaries: string[] = []; + const boundaryAngles: number[] = []; // Angles at each boundary - for (const d of data) { + for (let i = 0; i < data.length; i++) { + const d = data[i]; const pct = d.value / total; if (pct <= 0) continue; const angle = pct * Math.PI * 2; @@ -5787,8 +6142,10 @@ class FolkFlowsApp extends HTMLElement { const x2 = cx + r * Math.cos(currentAngle + angle); const y2 = cy + r * Math.sin(currentAngle + angle); const largeArc = angle > Math.PI ? 1 : 0; + const isSelected = this.selectedBudgetSegment === d.id; + const scale = isSelected ? 'transform="scale(1.04)" transform-origin="' + cx + ' ' + cy + '"' : ''; - paths.push(` + paths.push(` ${d.label}: ${d.value.toFixed(1)}% ($${Math.round(this.budgetTotalAmount * d.value / 100).toLocaleString()}) `); @@ -5803,6 +6160,15 @@ class FolkFlowsApp extends HTMLElement { } currentAngle += angle; + + // Store boundary angle between this segment and next (for drag handles) + if (data.length > 1) { + boundaryAngles.push(currentAngle); + const bx = cx + r * Math.cos(currentAngle); + const by = cy + r * Math.sin(currentAngle); + // Invisible wider line as drag handle + boundaries.push(``); + } } // Center label @@ -5811,8 +6177,9 @@ class FolkFlowsApp extends HTMLElement { ${this.fmtUsd(this.budgetTotalAmount)}` : ''; - return ` + return ` ${paths.join('\n')} + ${boundaries.join('\n')} ${labels.join('\n')} ${centerLabel} @@ -5827,7 +6194,16 @@ class FolkFlowsApp extends HTMLElement { return { id: c.segmentId, label: seg?.name || c.segmentId, value: c.avgPercentage, color: seg?.color || '#666' }; }).filter((d) => d.value > 0); + const myPieData = this.budgetSegments.map((seg) => ({ + id: seg.id, label: seg.name, value: this.myAllocation[seg.id] || 0, color: seg.color, + })).filter((d) => d.value > 0); + const authenticated = isAuthenticated(); + const isLive = this.localFirstClient?.isConnected ?? false; + + // Selected segment detail + const selSeg = this.selectedBudgetSegment ? this.budgetSegments.find(s => s.id === this.selectedBudgetSegment) : null; + const selCollective = selSeg ? this.collectiveAllocations.find(c => c.segmentId === selSeg.id) : null; return `
@@ -5836,6 +6212,7 @@ class FolkFlowsApp extends HTMLElement { ← Back to Flows

rBudgets

COLLECTIVE + ${isLive ? 'LIVE' : ''} ${this.budgetParticipantCount} participant${this.budgetParticipantCount !== 1 ? 's' : ''}
@@ -5843,17 +6220,31 @@ class FolkFlowsApp extends HTMLElement {
-

Collective Allocation

- ${this.renderPieChart(pieData, 280)} +

Collective Allocation

+

Click a wedge to select. Drag boundaries to adjust your allocation.

+ ${this.renderPieChart(myPieData.length > 0 ? myPieData : pieData, 280)}
${pieData.map((d) => ` -
+
${this.esc(d.label)}: ${d.value.toFixed(1)}%
`).join('')}
+ ${selSeg ? ` +
+
+
+ ${this.esc(selSeg.name)} +
+
+ Collective: ${(selCollective?.avgPercentage || 0).toFixed(1)}% + Your: ${this.myAllocation[selSeg.id] || 0}% + Voters: ${selCollective?.participantCount || 0} + ${this.budgetTotalAmount > 0 ? `${this.fmtUsd(Math.round(this.budgetTotalAmount * (selCollective?.avgPercentage || 0) / 100))}` : ''} +
+
` : ''}
@@ -5867,8 +6258,9 @@ class FolkFlowsApp extends HTMLElement { ${this.budgetSegments.map((seg) => { const val = this.myAllocation[seg.id] || 0; const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * val / 100) : 0; + const isSelected = this.selectedBudgetSegment === seg.id; return ` -
+
${this.esc(seg.name)} ${authenticated - ? `` + ? `` : `
Sign in to save your allocation
` } + ${authenticated ? `` : ''}
@@ -5900,7 +6293,7 @@ class FolkFlowsApp extends HTMLElement { const pct = collective?.avgPercentage || 0; const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * pct / 100) : 0; return ` -
+
${this.esc(seg.name)} ${pct.toFixed(1)}%${amount > 0 ? ` / $${amount.toLocaleString()}` : ''} @@ -5930,8 +6323,9 @@ class FolkFlowsApp extends HTMLElement { const pct = collective?.avgPercentage || 0; const myPct = this.myAllocation[seg.id] || 0; const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * pct / 100) : 0; + const isSelected = this.selectedBudgetSegment === seg.id; return ` - +
diff --git a/modules/rflows/local-first-client.ts b/modules/rflows/local-first-client.ts index 3abb458..ae7874a 100644 --- a/modules/rflows/local-first-client.ts +++ b/modules/rflows/local-first-client.ts @@ -154,6 +154,54 @@ export class FlowsLocalFirstClient { return doc?.activeFlowId || ''; } + // ── Budget CRUD ── + + saveBudgetAllocation(did: string, allocations: Record): void { + const docId = flowsDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Save budget allocation`, (d) => { + if (!d.budgetAllocations) d.budgetAllocations = {} as any; + d.budgetAllocations[did] = { + participantDid: did, + allocations, + updatedAt: Date.now(), + }; + }); + } + + addBudgetSegment(id: string, name: string, color: string, createdBy: string | null): void { + const docId = flowsDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Add budget segment ${name}`, (d) => { + if (!d.budgetSegments) d.budgetSegments = {} as any; + d.budgetSegments[id] = { name, color, createdBy }; + // Initialize all existing allocations with 0 for new segment + if (d.budgetAllocations) { + for (const alloc of Object.values(d.budgetAllocations)) { + if (!alloc.allocations[id]) alloc.allocations[id] = 0; + } + } + }); + } + + removeBudgetSegment(id: string): void { + const docId = flowsDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Remove budget segment`, (d) => { + if (d.budgetSegments?.[id]) delete d.budgetSegments[id]; + // Clean allocations + if (d.budgetAllocations) { + for (const alloc of Object.values(d.budgetAllocations)) { + if (alloc.allocations[id] !== undefined) delete alloc.allocations[id]; + } + } + }); + } + + setBudgetTotalAmount(amount: number): void { + const docId = flowsDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Set budget total`, (d) => { + d.budgetTotalAmount = amount; + }); + } + async disconnect(): Promise { await this.#sync.flush(); this.#sync.disconnect();