From af43e98812b8b2617ebb5ef94417493d5c1fa0ab Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 15 Mar 2026 16:11:32 -0700 Subject: [PATCH] feat(rflows): add rBudgets collective budget allocation sub-view Adds a new "rBudgets" sub-tab to rFlows where participants allocate budgets across departments via sliders, with a collective SVG pie chart showing aggregated results. Includes schema v4 migration, budget CRUD API routes, demo seed data (5 segments, 4 participants, $500k pool), and slider auto-normalization to 100%. Removes redundant "Flows" and "Flow Viewer" entries from outputPaths/subPageInfos. Co-Authored-By: Claude Opus 4.6 --- modules/rflows/components/folk-flows-app.ts | 420 +++++++++++++++++++- modules/rflows/lib/types.ts | 15 + modules/rflows/mod.ts | 175 +++++++- modules/rflows/schemas.ts | 17 +- 4 files changed, 612 insertions(+), 15 deletions(-) diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index a9d0373..c88375f 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -11,7 +11,7 @@ * mode — "demo" to use hardcoded demo data (no API) */ -import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation, MortgagePosition, ReinvestmentPosition } from "../lib/types"; +import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation, MortgagePosition, ReinvestmentPosition, BudgetSegment } from "../lib/types"; import { PORT_DEFS, deriveThresholds } from "../lib/types"; import { TourEngine } from "../../../shared/tour-engine"; import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation"; @@ -41,7 +41,7 @@ interface Transaction { description?: string; } -type View = "landing" | "detail" | "mortgage"; +type View = "landing" | "detail" | "mortgage" | "budgets"; interface NodeAnalyticsStats { totalInflow: number; @@ -179,6 +179,13 @@ class FolkFlowsApp extends HTMLElement { private borrowerMonthlyBudget = 1500; private showPoolOverview = false; + // Budget state + private budgetSegments: BudgetSegment[] = []; + private myAllocation: Record = {}; + private collectiveAllocations: { segmentId: string; avgPercentage: number; participantCount: number }[] = []; + private budgetTotalAmount = 0; + private budgetParticipantCount = 0; + // Tour engine private _tour!: TourEngine; private static readonly TOUR_STEPS = [ @@ -213,7 +220,12 @@ class FolkFlowsApp extends HTMLElement { // Read view attribute, default to canvas (detail) view const viewAttr = this.getAttribute("view"); - this.view = viewAttr === "mortgage" ? "mortgage" : "detail"; + this.view = viewAttr === "budgets" ? "budgets" : viewAttr === "mortgage" ? "mortgage" : "detail"; + + if (this.view === "budgets") { + this.loadBudgetData(); + return; + } if (this.view === "mortgage") { this.loadMortgageData(); @@ -616,6 +628,7 @@ class FolkFlowsApp extends HTMLElement { } private renderView(): string { + if (this.view === "budgets") return this.renderBudgetsTab(); if (this.view === "mortgage") return this.renderMortgageTab(); if (this.view === "detail") return this.renderDetail(); return this.renderLanding(); @@ -5475,6 +5488,36 @@ class FolkFlowsApp extends HTMLElement { this.render(); }); } + + // Budget tab listeners + if (this.view === "budgets") { + this.shadow.querySelectorAll('[data-budget-slider]').forEach((slider) => { + slider.addEventListener('input', (e) => { + const segId = (slider as HTMLElement).dataset.budgetSlider!; + this.myAllocation[segId] = parseInt((e.target as HTMLInputElement).value) || 0; + this.normalizeBudgetSliders(segId); + this.render(); + }); + }); + + this.shadow.querySelector('[data-budget-action="save"]')?.addEventListener('click', () => { + this.saveBudgetAllocation(); + }); + + this.shadow.querySelector('[data-budget-action="add-segment"]')?.addEventListener('click', () => { + this.addBudgetSegment(); + }); + + this.shadow.querySelectorAll('[data-budget-remove]').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const segId = (btn as HTMLElement).dataset.budgetRemove!; + if (confirm(`Remove segment "${this.budgetSegments.find(s => s.id === segId)?.name}"?`)) { + this.removeBudgetSegment(segId); + } + }); + }); + } } private cleanupCanvas() { @@ -5542,6 +5585,377 @@ class FolkFlowsApp extends HTMLElement { this._tour.start(); } + // ─── Budget tab ───────────────────────────────────────── + + private async loadBudgetData() { + this.loading = true; + this.render(); + + const base = this.getApiBase(); + try { + const res = await fetch(`${base}/api/budgets?space=${encodeURIComponent(this.space)}`); + if (res.ok) { + const data = await res.json(); + this.budgetSegments = data.segments || []; + this.collectiveAllocations = data.collective || []; + this.budgetTotalAmount = data.totalAmount || 0; + this.budgetParticipantCount = data.participantCount || 0; + + // Load my allocation if authenticated + const session = getSession(); + if (session) { + const myDid = (session.claims as any).did || session.claims.sub; + const myAlloc = (data.allocations || []).find((a: any) => a.participantDid === myDid); + if (myAlloc) this.myAllocation = myAlloc.allocations; + } + } + } catch (err) { + console.warn('[rBudgets] Failed to load data:', err); + } + + // Demo fallback if no segments + if (this.budgetSegments.length === 0) { + this.budgetSegments = [ + { id: 'eng', name: 'Engineering', color: '#3b82f6', createdBy: null }, + { id: 'ops', name: 'Operations', color: '#10b981', createdBy: null }, + { id: 'mkt', name: 'Marketing', color: '#f59e0b', createdBy: null }, + { id: 'com', name: 'Community', color: '#8b5cf6', createdBy: null }, + { id: 'res', name: 'Research', color: '#ef4444', createdBy: null }, + ]; + this.collectiveAllocations = [ + { segmentId: 'eng', avgPercentage: 32.5, participantCount: 4 }, + { segmentId: 'ops', avgPercentage: 15, participantCount: 4 }, + { segmentId: 'mkt', avgPercentage: 17.5, participantCount: 4 }, + { segmentId: 'com', avgPercentage: 18.75, participantCount: 4 }, + { segmentId: 'res', avgPercentage: 16.25, participantCount: 4 }, + ]; + this.budgetTotalAmount = 500000; + this.budgetParticipantCount = 4; + } + + // Initialize myAllocation with equal splits if empty + if (Object.keys(this.myAllocation).length === 0 && this.budgetSegments.length > 0) { + const each = Math.floor(100 / this.budgetSegments.length); + const remainder = 100 - each * this.budgetSegments.length; + this.budgetSegments.forEach((seg, i) => { + this.myAllocation[seg.id] = each + (i === 0 ? remainder : 0); + }); + } + + this.loading = false; + this.render(); + } + + private async saveBudgetAllocation() { + const session = getSession(); + if (!session) return; + + const base = this.getApiBase(); + try { + await fetch(`${base}/api/budgets/allocate`, { + method: 'POST', + 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); + } + } + + 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]; + + if (session) { + const base = this.getApiBase(); + try { + await fetch(`${base}/api/budgets/segments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` }, + body: JSON.stringify({ space: this.space, action: 'add', name, color }), + }); + await this.loadBudgetData(); + return; + } catch {} + } + + // 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 (session) { + const base = this.getApiBase(); + try { + await fetch(`${base}/api/budgets/segments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` }, + body: JSON.stringify({ space: this.space, action: 'remove', segmentId }), + }); + await this.loadBudgetData(); + return; + } catch {} + } + + // Local-only fallback + this.budgetSegments = this.budgetSegments.filter((s) => s.id !== segmentId); + delete this.myAllocation[segmentId]; + this.collectiveAllocations = this.collectiveAllocations.filter((c) => c.segmentId !== segmentId); + this.normalizeBudgetSliders(); + this.render(); + } + + private normalizeBudgetSliders(changedId?: string) { + const ids = this.budgetSegments.map((s) => s.id); + if (ids.length === 0) return; + + const total = ids.reduce((s, id) => s + (this.myAllocation[id] || 0), 0); + if (total === 100) return; + + if (changedId) { + const changedVal = this.myAllocation[changedId] || 0; + const others = ids.filter((id) => id !== changedId); + const othersTotal = others.reduce((s, id) => s + (this.myAllocation[id] || 0), 0); + const remaining = 100 - changedVal; + + if (othersTotal === 0) { + // Distribute remaining equally + const each = Math.floor(remaining / others.length); + const rem = remaining - each * others.length; + others.forEach((id, i) => { this.myAllocation[id] = each + (i === 0 ? rem : 0); }); + } else { + // Scale proportionally + const scale = remaining / othersTotal; + let assigned = 0; + others.forEach((id, i) => { + if (i === others.length - 1) { + this.myAllocation[id] = remaining - assigned; + } else { + const val = Math.round((this.myAllocation[id] || 0) * scale); + this.myAllocation[id] = val; + assigned += val; + } + }); + } + } else { + // Simple normalize + const scale = 100 / total; + let assigned = 0; + ids.forEach((id, i) => { + if (i === ids.length - 1) { + this.myAllocation[id] = 100 - assigned; + } else { + const val = Math.round((this.myAllocation[id] || 0) * scale); + this.myAllocation[id] = val; + assigned += val; + } + }); + } + } + + 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) { + return ` + + No data + `; + } + + const cx = size / 2, cy = size / 2, r = size / 2 - 8; + let currentAngle = -Math.PI / 2; // Start at top + const paths: string[] = []; + const labels: string[] = []; + + for (const d of data) { + const pct = d.value / total; + if (pct <= 0) continue; + const angle = pct * Math.PI * 2; + const x1 = cx + r * Math.cos(currentAngle); + const y1 = cy + r * Math.sin(currentAngle); + const x2 = cx + r * Math.cos(currentAngle + angle); + const y2 = cy + r * Math.sin(currentAngle + angle); + const largeArc = angle > Math.PI ? 1 : 0; + + paths.push(` + ${d.label}: ${d.value.toFixed(1)}% ($${Math.round(this.budgetTotalAmount * d.value / 100).toLocaleString()}) + `); + + // Label at midpoint of arc + if (pct > 0.05) { + const midAngle = currentAngle + angle / 2; + const labelR = r * 0.65; + const lx = cx + labelR * Math.cos(midAngle); + const ly = cy + labelR * Math.sin(midAngle); + labels.push(`${d.label.slice(0, 6)}`); + labels.push(`${d.value.toFixed(1)}%`); + } + + currentAngle += angle; + } + + // Center label + const centerLabel = this.budgetTotalAmount > 0 + ? `Total + ${this.fmtUsd(this.budgetTotalAmount)}` + : ''; + + return ` + ${paths.join('\n')} + ${labels.join('\n')} + + ${centerLabel} + `; + } + + private renderBudgetsTab(): string { + if (this.loading) return '
Loading budget data...
'; + + const pieData = this.collectiveAllocations.map((c) => { + const seg = this.budgetSegments.find((s) => s.id === c.segmentId); + return { id: c.segmentId, label: seg?.name || c.segmentId, value: c.avgPercentage, color: seg?.color || '#666' }; + }).filter((d) => d.value > 0); + + const authenticated = isAuthenticated(); + + return ` +
+ +
+ ← Back to Flows +

rBudgets

+ COLLECTIVE + ${this.budgetParticipantCount} participant${this.budgetParticipantCount !== 1 ? 's' : ''} +
+ + +
+ +
+

Collective Allocation

+ ${this.renderPieChart(pieData, 280)} + +
+ ${pieData.map((d) => ` +
+
+ ${this.esc(d.label)}: ${d.value.toFixed(1)}% +
+ `).join('')} +
+
+ + +
+
+

My Allocation

+ Total: ${Object.values(this.myAllocation).reduce((s, v) => s + v, 0)}% +
+ +
+ ${this.budgetSegments.map((seg) => { + const val = this.myAllocation[seg.id] || 0; + const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * val / 100) : 0; + return ` +
+
+ ${this.esc(seg.name)} + + ${val}% + ${this.budgetTotalAmount > 0 ? `$${amount.toLocaleString()}` : ''} +
`; + }).join('')} +
+ +
+ ${authenticated + ? `` + : `
Sign in to save your allocation
` + } +
+
+
+ + +
+
+

Segments

+ +
+
+ ${this.budgetSegments.map((seg) => { + const collective = this.collectiveAllocations.find((c) => c.segmentId === seg.id); + 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()}` : ''} + +
`; + }).join('')} +
+
+ + +
+

Allocation Breakdown

+
+ + + + + + + + + + + + ${this.budgetSegments.map((seg) => { + const collective = this.collectiveAllocations.find((c) => c.segmentId === seg.id); + const pct = collective?.avgPercentage || 0; + const myPct = this.myAllocation[seg.id] || 0; + const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * pct / 100) : 0; + return ` + + + + + + + `; + }).join('')} + +
SegmentCollective %AmountYour %Bar
+
+
+ ${this.esc(seg.name)} +
+
${pct.toFixed(1)}%${amount > 0 ? '$' + amount.toLocaleString() : '-'}${myPct}% +
+
+
+
+
+
+
+
`; + } + // ─── Mortgage tab ─────────────────────────────────────── private async loadMortgageData() { diff --git a/modules/rflows/lib/types.ts b/modules/rflows/lib/types.ts index 263de9d..5731ee6 100644 --- a/modules/rflows/lib/types.ts +++ b/modules/rflows/lib/types.ts @@ -133,6 +133,21 @@ export interface ReinvestmentPosition { lastUpdated: number; } +// ─── Budget types ───────────────────────────────────── + +export interface BudgetSegment { + id: string; + name: string; + color: string; + createdBy: string | null; +} + +export interface BudgetAllocation { + participantDid: string; + allocations: Record; // segmentId → percentage (0-100) + updatedAt: number; +} + // ─── Port definitions ───────────────────────────────── export type PortDirection = "in" | "out"; diff --git a/modules/rflows/mod.ts b/modules/rflows/mod.ts index 0102128..fc68e5b 100644 --- a/modules/rflows/mod.ts +++ b/modules/rflows/mod.ts @@ -62,6 +62,16 @@ function ensureDoc(space: string): FlowsDoc { }); doc = _syncServer!.getDoc(docId)!; } + // Migrate v3 → v4: add budget fields + if (doc.meta.version < 4) { + _syncServer!.changeDoc(docId, 'migrate to v4', (d) => { + if (!d.budgetSegments) d.budgetSegments = {} as any; + if (!d.budgetAllocations) d.budgetAllocations = {} as any; + if (!d.budgetTotalAmount) d.budgetTotalAmount = 0 as any; + d.meta.version = 4; + }); + doc = _syncServer!.getDoc(docId)!; + } return doc; } @@ -664,6 +674,113 @@ routes.post("/api/mortgage/positions", async (c) => { return c.json(position, 201); }); +// ─── Budget API routes ─────────────────────────────────── + +routes.get("/api/budgets", async (c) => { + const space = c.req.query("space") || "demo"; + const doc = ensureDoc(space); + + const segments = Object.entries(doc.budgetSegments || {}).map(([id, s]) => ({ + id, name: s.name, color: s.color, createdBy: s.createdBy, + })); + + const allocations = Object.values(doc.budgetAllocations || {}); + + // Compute collective averages per segment + const collective: { segmentId: string; avgPercentage: number; participantCount: number }[] = []; + if (segments.length > 0 && allocations.length > 0) { + for (const seg of segments) { + const vals = allocations.map((a) => a.allocations[seg.id] || 0); + const avg = vals.reduce((s, v) => s + v, 0) / vals.length; + collective.push({ segmentId: seg.id, avgPercentage: Math.round(avg * 100) / 100, participantCount: vals.filter((v) => v > 0).length }); + } + } + + return c.json({ + segments, + allocations, + collective, + totalAmount: doc.budgetTotalAmount || 0, + participantCount: allocations.length, + }); +}); + +routes.post("/api/budgets/allocate", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const { space, allocations } = await c.req.json() as { space?: string; allocations?: Record }; + if (!allocations) return c.json({ error: "allocations required" }, 400); + + const spaceSlug = space || "demo"; + const docId = flowsDocId(spaceSlug); + ensureDoc(spaceSlug); + + const did = (claims as any).did || claims.sub; + _syncServer!.changeDoc(docId, 'save budget allocation', (d) => { + d.budgetAllocations[did] = { + participantDid: did, + allocations: allocations as any, + updatedAt: Date.now(), + } as any; + }); + + return c.json({ ok: true }); +}); + +routes.get("/api/budgets/segments", async (c) => { + const space = c.req.query("space") || "demo"; + const doc = ensureDoc(space); + const segments = Object.entries(doc.budgetSegments || {}).map(([id, s]) => ({ + id, name: s.name, color: s.color, createdBy: s.createdBy, + })); + return c.json(segments); +}); + +routes.post("/api/budgets/segments", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const { space, action, segmentId, name, color } = await c.req.json() as { + space?: string; action: 'add' | 'remove'; segmentId?: string; name?: string; color?: string; + }; + + const spaceSlug = space || "demo"; + const docId = flowsDocId(spaceSlug); + ensureDoc(spaceSlug); + + const did = (claims as any).did || claims.sub; + + if (action === 'add') { + if (!name) return c.json({ error: "name required" }, 400); + const id = segmentId || crypto.randomUUID(); + _syncServer!.changeDoc(docId, 'add budget segment', (d) => { + d.budgetSegments[id] = { name: name as any, color: (color || '#6366f1') as any, createdBy: did as any }; + }); + return c.json({ ok: true, id }); + } + + if (action === 'remove') { + if (!segmentId) return c.json({ error: "segmentId required" }, 400); + _syncServer!.changeDoc(docId, 'remove budget segment', (d) => { + delete d.budgetSegments[segmentId]; + // Remove this segment from all allocations + for (const alloc of Object.values(d.budgetAllocations)) { + delete alloc.allocations[segmentId]; + } + }); + return c.json({ ok: true }); + } + + return c.json({ error: "action must be 'add' or 'remove'" }, 400); +}); + // ─── Page routes ──────────────────────────────────────── const flowsScripts = ` @@ -703,6 +820,21 @@ routes.get("/mortgage", (c) => { })); }); +// Budgets sub-tab +routes.get("/budgets", (c) => { + const spaceSlug = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `${spaceSlug} — rBudgets | rFlows | rSpace`, + moduleId: "rflows", + spaceSlug, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + scripts: flowsScripts, + styles: flowsStyles, + })); +}); + // Flow detail — specific flow from API routes.get("/flow/:flowId", (c) => { const spaceSlug = c.req.param("space") || "demo"; @@ -803,6 +935,32 @@ function seedTemplateFlows(space: string) { }); console.log(`[Flows] Mortgage demo seeded for "${space}"`); } + + // Seed budget demo data if empty + if (Object.keys(doc.budgetSegments || {}).length === 0) { + const docId = flowsDocId(space); + const demoSegments: Record = { + 'eng': { name: 'Engineering', color: '#3b82f6', createdBy: null }, + 'ops': { name: 'Operations', color: '#10b981', createdBy: null }, + 'mkt': { name: 'Marketing', color: '#f59e0b', createdBy: null }, + 'com': { name: 'Community', color: '#8b5cf6', createdBy: null }, + 'res': { name: 'Research', color: '#ef4444', createdBy: null }, + }; + + const demoAllocations: Record; updatedAt: number }> = { + 'did:key:alice123': { participantDid: 'did:key:alice123', allocations: { eng: 35, ops: 15, mkt: 20, com: 15, res: 15 }, updatedAt: Date.now() }, + 'did:key:bob456': { participantDid: 'did:key:bob456', allocations: { eng: 25, ops: 20, mkt: 10, com: 30, res: 15 }, updatedAt: Date.now() }, + 'did:key:carol789': { participantDid: 'did:key:carol789', allocations: { eng: 30, ops: 10, mkt: 25, com: 10, res: 25 }, updatedAt: Date.now() }, + 'did:key:dave012': { participantDid: 'did:key:dave012', allocations: { eng: 40, ops: 15, mkt: 15, com: 20, res: 10 }, updatedAt: Date.now() }, + }; + + _syncServer!.changeDoc(docId, 'seed budget demo', (d) => { + for (const [id, seg] of Object.entries(demoSegments)) d.budgetSegments[id] = seg as any; + for (const [did, alloc] of Object.entries(demoAllocations)) d.budgetAllocations[did] = alloc as any; + d.budgetTotalAmount = 500000 as any; + }); + console.log(`[Flows] Budget demo seeded for "${space}"`); + } } export const flowsModule: RSpaceModule = { @@ -900,20 +1058,19 @@ export const flowsModule: RSpaceModule = { ], acceptsFeeds: ["governance", "data"], outputPaths: [ - { path: "budgets", name: "Budgets", icon: "💰", description: "Budget allocations and funnels" }, - { path: "flows", name: "Flows", icon: "🌊", description: "Revenue and resource flow visualizations" }, + { path: "budgets", name: "rBudgets", icon: "💰", description: "Collective budget allocation with pie charts" }, ], subPageInfos: [ { - path: "flow", - title: "Flow Viewer", - icon: "🌊", + path: "budgets", + title: "rBudgets", + icon: "💰", tagline: "rFlows Tool", - description: "Visualize a single budget flow — deposits, withdrawals, funnel allocations, and real-time balance. Drill into transactions and manage outcomes.", + description: "Collective budget allocation where participants distribute funds across departments using pie charts. Aggregated view shows the group consensus.", features: [ - { icon: "📈", title: "River Visualization", text: "See funds flow through funnels and outcomes as an animated river diagram." }, - { icon: "💸", title: "Deposits & Withdrawals", text: "Track every transaction with full history and on-chain verification." }, - { icon: "🎯", title: "Outcome Tracking", text: "Define funding outcomes and monitor how capital reaches its destination." }, + { icon: "🥧", title: "Collective Pie Chart", text: "See the aggregated budget breakdown from all participants as a dynamic pie chart." }, + { icon: "🎚️", title: "Personal Sliders", text: "Adjust your own allocation across departments — sliders auto-normalize to 100%." }, + { icon: "👥", title: "Participant Tracking", text: "View how many people contributed and the consensus distribution." }, ], }, { diff --git a/modules/rflows/schemas.ts b/modules/rflows/schemas.ts index 7cba8a3..f332062 100644 --- a/modules/rflows/schemas.ts +++ b/modules/rflows/schemas.ts @@ -6,6 +6,8 @@ * * v1: space-flow associations only * v2: adds canvasFlows (full node data) and activeFlowId + * v3: adds mortgagePositions and reinvestmentPositions + * v4: adds budgetSegments, budgetAllocations, budgetTotalAmount */ import type { DocSchema } from '../../shared/local-first/document'; @@ -43,6 +45,9 @@ export interface FlowsDoc { activeFlowId: string; mortgagePositions: Record; reinvestmentPositions: Record; + budgetSegments: Record; + budgetAllocations: Record; updatedAt: number }>; + budgetTotalAmount: number; } // ── Schema registration ── @@ -50,12 +55,12 @@ export interface FlowsDoc { export const flowsSchema: DocSchema = { module: 'flows', collection: 'data', - version: 3, + version: 4, init: (): FlowsDoc => ({ meta: { module: 'flows', collection: 'data', - version: 3, + version: 4, spaceSlug: '', createdAt: Date.now(), }, @@ -64,13 +69,19 @@ export const flowsSchema: DocSchema = { activeFlowId: '', mortgagePositions: {}, reinvestmentPositions: {}, + budgetSegments: {}, + budgetAllocations: {}, + budgetTotalAmount: 0, }), migrate: (doc: any, _fromVersion: number) => { if (!doc.canvasFlows) doc.canvasFlows = {}; if (!doc.activeFlowId) doc.activeFlowId = ''; if (!doc.mortgagePositions) doc.mortgagePositions = {}; if (!doc.reinvestmentPositions) doc.reinvestmentPositions = {}; - doc.meta.version = 3; + if (!doc.budgetSegments) doc.budgetSegments = {}; + if (!doc.budgetAllocations) doc.budgetAllocations = {}; + if (doc.budgetTotalAmount === undefined) doc.budgetTotalAmount = 0; + doc.meta.version = 4; return doc; }, };