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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-15 16:11:32 -07:00
parent cab80f30e7
commit af43e98812
4 changed files with 612 additions and 15 deletions

View File

@ -11,7 +11,7 @@
* mode "demo" to use hardcoded demo data (no API) * 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 { PORT_DEFS, deriveThresholds } from "../lib/types";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation"; import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
@ -41,7 +41,7 @@ interface Transaction {
description?: string; description?: string;
} }
type View = "landing" | "detail" | "mortgage"; type View = "landing" | "detail" | "mortgage" | "budgets";
interface NodeAnalyticsStats { interface NodeAnalyticsStats {
totalInflow: number; totalInflow: number;
@ -179,6 +179,13 @@ class FolkFlowsApp extends HTMLElement {
private borrowerMonthlyBudget = 1500; private borrowerMonthlyBudget = 1500;
private showPoolOverview = false; private showPoolOverview = false;
// Budget state
private budgetSegments: BudgetSegment[] = [];
private myAllocation: Record<string, number> = {};
private collectiveAllocations: { segmentId: string; avgPercentage: number; participantCount: number }[] = [];
private budgetTotalAmount = 0;
private budgetParticipantCount = 0;
// Tour engine // Tour engine
private _tour!: TourEngine; private _tour!: TourEngine;
private static readonly TOUR_STEPS = [ private static readonly TOUR_STEPS = [
@ -213,7 +220,12 @@ class FolkFlowsApp extends HTMLElement {
// Read view attribute, default to canvas (detail) view // Read view attribute, default to canvas (detail) view
const viewAttr = this.getAttribute("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") { if (this.view === "mortgage") {
this.loadMortgageData(); this.loadMortgageData();
@ -616,6 +628,7 @@ class FolkFlowsApp extends HTMLElement {
} }
private renderView(): string { private renderView(): string {
if (this.view === "budgets") return this.renderBudgetsTab();
if (this.view === "mortgage") return this.renderMortgageTab(); if (this.view === "mortgage") return this.renderMortgageTab();
if (this.view === "detail") return this.renderDetail(); if (this.view === "detail") return this.renderDetail();
return this.renderLanding(); return this.renderLanding();
@ -5475,6 +5488,36 @@ class FolkFlowsApp extends HTMLElement {
this.render(); 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() { private cleanupCanvas() {
@ -5542,6 +5585,377 @@ class FolkFlowsApp extends HTMLElement {
this._tour.start(); 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 `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<circle cx="${size / 2}" cy="${size / 2}" r="${size / 2 - 4}" fill="none" stroke="var(--rs-border, #2a2a3e)" stroke-width="2"/>
<text x="${size / 2}" y="${size / 2}" text-anchor="middle" dominant-baseline="middle" fill="var(--rs-text-secondary)" font-size="14">No data</text>
</svg>`;
}
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(`<path d="M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z" fill="${d.color}" stroke="var(--rs-bg, #0a0a1a)" stroke-width="2" opacity="0.9" data-segment="${d.id}">
<title>${d.label}: ${d.value.toFixed(1)}% ($${Math.round(this.budgetTotalAmount * d.value / 100).toLocaleString()})</title>
</path>`);
// 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(`<text x="${lx}" y="${ly}" text-anchor="middle" dominant-baseline="middle" fill="white" font-size="${pct > 0.1 ? 12 : 10}" font-weight="600" pointer-events="none">${d.label.slice(0, 6)}</text>`);
labels.push(`<text x="${lx}" y="${ly + 14}" text-anchor="middle" dominant-baseline="middle" fill="rgba(255,255,255,0.8)" font-size="10" pointer-events="none">${d.value.toFixed(1)}%</text>`);
}
currentAngle += angle;
}
// Center label
const centerLabel = this.budgetTotalAmount > 0
? `<text x="${cx}" y="${cy - 6}" text-anchor="middle" dominant-baseline="middle" fill="var(--rs-text-secondary)" font-size="10">Total</text>
<text x="${cx}" y="${cy + 10}" text-anchor="middle" dominant-baseline="middle" fill="var(--rs-text-primary)" font-size="14" font-weight="700">${this.fmtUsd(this.budgetTotalAmount)}</text>`
: '';
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));">
${paths.join('\n')}
${labels.join('\n')}
<circle cx="${cx}" cy="${cy}" r="${r * 0.32}" fill="var(--rs-bg, #0a0a1a)" stroke="var(--rs-border, #2a2a3e)" stroke-width="1"/>
${centerLabel}
</svg>`;
}
private renderBudgetsTab(): string {
if (this.loading) return '<div class="flows-loading">Loading budget data...</div>';
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 `
<div class="budget-tab" style="padding: 24px; max-width: 1200px; margin: 0 auto;">
<!-- Header -->
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 24px;">
<a href="/${this.space === 'demo' ? 'demo/' : ''}rflows" style="color: var(--rs-text-secondary); text-decoration: none; font-size: 14px;">&larr; Back to Flows</a>
<h2 style="margin: 0; font-size: 24px; color: var(--rs-text-primary);">rBudgets</h2>
<span style="background: rgba(99,102,241,0.15); color: #6366f1; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600;">COLLECTIVE</span>
<span style="margin-left: auto; color: var(--rs-text-secondary); font-size: 13px;">${this.budgetParticipantCount} participant${this.budgetParticipantCount !== 1 ? 's' : ''}</span>
</div>
<!-- Main layout: pie chart + sliders -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 24px;">
<!-- Collective Pie Chart -->
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 24px; border: 1px solid var(--rs-border, #2a2a3e); display: flex; flex-direction: column; align-items: center;">
<h3 style="margin: 0 0 16px; font-size: 16px; color: var(--rs-text-primary); width: 100%;">Collective Allocation</h3>
${this.renderPieChart(pieData, 280)}
<!-- Legend -->
<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; justify-content: center;">
${pieData.map((d) => `
<div style="display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--rs-text-secondary);">
<div style="width: 10px; height: 10px; border-radius: 2px; background: ${d.color};"></div>
${this.esc(d.label)}: ${d.value.toFixed(1)}%
</div>
`).join('')}
</div>
</div>
<!-- My Allocation Sliders -->
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 24px; border: 1px solid var(--rs-border, #2a2a3e);">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px;">
<h3 style="margin: 0; font-size: 16px; color: var(--rs-text-primary);">My Allocation</h3>
<span style="font-size: 12px; color: var(--rs-text-secondary);">Total: ${Object.values(this.myAllocation).reduce((s, v) => s + v, 0)}%</span>
</div>
<div style="display: flex; flex-direction: column; gap: 12px;">
${this.budgetSegments.map((seg) => {
const val = this.myAllocation[seg.id] || 0;
const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * val / 100) : 0;
return `
<div style="display: flex; align-items: center; gap: 12px;">
<div style="width: 10px; height: 10px; border-radius: 2px; background: ${seg.color}; flex-shrink: 0;"></div>
<span style="font-size: 13px; color: var(--rs-text-primary); width: 90px; flex-shrink: 0;">${this.esc(seg.name)}</span>
<input type="range" min="0" max="100" value="${val}" data-budget-slider="${seg.id}"
style="flex: 1; accent-color: ${seg.color}; cursor: pointer;">
<span style="font-size: 13px; color: var(--rs-text-primary); width: 36px; text-align: right; font-variant-numeric: tabular-nums;">${val}%</span>
${this.budgetTotalAmount > 0 ? `<span style="font-size: 11px; color: var(--rs-text-secondary); width: 70px; text-align: right;">$${amount.toLocaleString()}</span>` : ''}
</div>`;
}).join('')}
</div>
<div style="display: flex; gap: 8px; margin-top: 16px;">
${authenticated
? `<button data-budget-action="save" style="flex: 1; padding: 10px; background: #6366f1; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600;">Save Allocation</button>`
: `<div style="flex: 1; padding: 10px; background: rgba(99,102,241,0.1); color: var(--rs-text-secondary); border-radius: 8px; text-align: center; font-size: 13px;">Sign in to save your allocation</div>`
}
</div>
</div>
</div>
<!-- Segment Management -->
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; border: 1px solid var(--rs-border, #2a2a3e); margin-bottom: 24px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
<h3 style="margin: 0; font-size: 16px; color: var(--rs-text-primary);">Segments</h3>
<button data-budget-action="add-segment" style="padding: 6px 14px; background: rgba(99,102,241,0.15); color: #6366f1; border: 1px solid rgba(99,102,241,0.3); border-radius: 8px; cursor: pointer; font-size: 12px; font-weight: 600;">+ Add Segment</button>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${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 `
<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: var(--rs-bg, #0a0a1a); border-radius: 8px; border: 1px solid var(--rs-border, #2a2a3e);">
<div style="width: 12px; height: 12px; border-radius: 3px; background: ${seg.color};"></div>
<span style="font-size: 13px; color: var(--rs-text-primary);">${this.esc(seg.name)}</span>
<span style="font-size: 11px; color: var(--rs-text-secondary);">${pct.toFixed(1)}%${amount > 0 ? ` / $${amount.toLocaleString()}` : ''}</span>
<button data-budget-remove="${seg.id}" style="background: none; border: none; color: var(--rs-text-secondary); cursor: pointer; font-size: 14px; padding: 0 2px; opacity: 0.5;" title="Remove segment">&times;</button>
</div>`;
}).join('')}
</div>
</div>
<!-- Allocation Summary Table -->
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; border: 1px solid var(--rs-border, #2a2a3e);">
<h3 style="margin: 0 0 12px; font-size: 16px; color: var(--rs-text-primary);">Allocation Breakdown</h3>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
<thead>
<tr style="border-bottom: 1px solid var(--rs-border, #2a2a3e);">
<th style="text-align: left; padding: 8px; color: var(--rs-text-secondary); font-weight: 500;">Segment</th>
<th style="text-align: right; padding: 8px; color: var(--rs-text-secondary); font-weight: 500;">Collective %</th>
<th style="text-align: right; padding: 8px; color: var(--rs-text-secondary); font-weight: 500;">Amount</th>
<th style="text-align: right; padding: 8px; color: var(--rs-text-secondary); font-weight: 500;">Your %</th>
<th style="text-align: left; padding: 8px; color: var(--rs-text-secondary); font-weight: 500;">Bar</th>
</tr>
</thead>
<tbody>
${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 `
<tr style="border-bottom: 1px solid var(--rs-border, #2a2a3e);">
<td style="padding: 8px; color: var(--rs-text-primary);">
<div style="display: flex; align-items: center; gap: 6px;">
<div style="width: 8px; height: 8px; border-radius: 2px; background: ${seg.color};"></div>
${this.esc(seg.name)}
</div>
</td>
<td style="text-align: right; padding: 8px; color: var(--rs-text-primary); font-variant-numeric: tabular-nums;">${pct.toFixed(1)}%</td>
<td style="text-align: right; padding: 8px; color: var(--rs-text-secondary); font-variant-numeric: tabular-nums;">${amount > 0 ? '$' + amount.toLocaleString() : '-'}</td>
<td style="text-align: right; padding: 8px; color: var(--rs-text-primary); font-variant-numeric: tabular-nums;">${myPct}%</td>
<td style="padding: 8px; width: 140px;">
<div style="height: 8px; background: var(--rs-bg, #0a0a1a); border-radius: 4px; overflow: hidden; position: relative;">
<div style="height: 100%; width: ${pct}%; background: ${seg.color}; border-radius: 4px; transition: width 0.3s ease;"></div>
<div style="position: absolute; top: 0; height: 100%; width: 2px; left: ${myPct}%; background: white; opacity: 0.7;"></div>
</div>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>
</div>
</div>`;
}
// ─── Mortgage tab ─────────────────────────────────────── // ─── Mortgage tab ───────────────────────────────────────
private async loadMortgageData() { private async loadMortgageData() {

View File

@ -133,6 +133,21 @@ export interface ReinvestmentPosition {
lastUpdated: number; lastUpdated: number;
} }
// ─── Budget types ─────────────────────────────────────
export interface BudgetSegment {
id: string;
name: string;
color: string;
createdBy: string | null;
}
export interface BudgetAllocation {
participantDid: string;
allocations: Record<string, number>; // segmentId → percentage (0-100)
updatedAt: number;
}
// ─── Port definitions ───────────────────────────────── // ─── Port definitions ─────────────────────────────────
export type PortDirection = "in" | "out"; export type PortDirection = "in" | "out";

View File

@ -62,6 +62,16 @@ function ensureDoc(space: string): FlowsDoc {
}); });
doc = _syncServer!.getDoc<FlowsDoc>(docId)!; doc = _syncServer!.getDoc<FlowsDoc>(docId)!;
} }
// Migrate v3 → v4: add budget fields
if (doc.meta.version < 4) {
_syncServer!.changeDoc<FlowsDoc>(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<FlowsDoc>(docId)!;
}
return doc; return doc;
} }
@ -664,6 +674,113 @@ routes.post("/api/mortgage/positions", async (c) => {
return c.json(position, 201); 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<string, number> };
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<FlowsDoc>(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<FlowsDoc>(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<FlowsDoc>(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 ──────────────────────────────────────── // ─── Page routes ────────────────────────────────────────
const flowsScripts = ` 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: `<folk-flows-app space="${spaceSlug}" view="budgets"${spaceSlug === "demo" ? ' mode="demo"' : ''}></folk-flows-app>`,
scripts: flowsScripts,
styles: flowsStyles,
}));
});
// Flow detail — specific flow from API // Flow detail — specific flow from API
routes.get("/flow/:flowId", (c) => { routes.get("/flow/:flowId", (c) => {
const spaceSlug = c.req.param("space") || "demo"; const spaceSlug = c.req.param("space") || "demo";
@ -803,6 +935,32 @@ function seedTemplateFlows(space: string) {
}); });
console.log(`[Flows] Mortgage demo seeded for "${space}"`); 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<string, { name: string; color: string; createdBy: string | null }> = {
'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<string, { participantDid: string; allocations: Record<string, number>; 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<FlowsDoc>(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 = { export const flowsModule: RSpaceModule = {
@ -900,20 +1058,19 @@ export const flowsModule: RSpaceModule = {
], ],
acceptsFeeds: ["governance", "data"], acceptsFeeds: ["governance", "data"],
outputPaths: [ outputPaths: [
{ path: "budgets", name: "Budgets", icon: "💰", description: "Budget allocations and funnels" }, { path: "budgets", name: "rBudgets", icon: "💰", description: "Collective budget allocation with pie charts" },
{ path: "flows", name: "Flows", icon: "🌊", description: "Revenue and resource flow visualizations" },
], ],
subPageInfos: [ subPageInfos: [
{ {
path: "flow", path: "budgets",
title: "Flow Viewer", title: "rBudgets",
icon: "🌊", icon: "💰",
tagline: "rFlows Tool", 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: [ features: [
{ icon: "📈", title: "River Visualization", text: "See funds flow through funnels and outcomes as an animated river diagram." }, { icon: "🥧", title: "Collective Pie Chart", text: "See the aggregated budget breakdown from all participants as a dynamic pie chart." },
{ icon: "💸", title: "Deposits & Withdrawals", text: "Track every transaction with full history and on-chain verification." }, { icon: "🎚️", title: "Personal Sliders", text: "Adjust your own allocation across departments — sliders auto-normalize to 100%." },
{ icon: "🎯", title: "Outcome Tracking", text: "Define funding outcomes and monitor how capital reaches its destination." }, { icon: "👥", title: "Participant Tracking", text: "View how many people contributed and the consensus distribution." },
], ],
}, },
{ {

View File

@ -6,6 +6,8 @@
* *
* v1: space-flow associations only * v1: space-flow associations only
* v2: adds canvasFlows (full node data) and activeFlowId * 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'; import type { DocSchema } from '../../shared/local-first/document';
@ -43,6 +45,9 @@ export interface FlowsDoc {
activeFlowId: string; activeFlowId: string;
mortgagePositions: Record<string, MortgagePosition>; mortgagePositions: Record<string, MortgagePosition>;
reinvestmentPositions: Record<string, ReinvestmentPosition>; reinvestmentPositions: Record<string, ReinvestmentPosition>;
budgetSegments: Record<string, { name: string; color: string; createdBy: string | null }>;
budgetAllocations: Record<string, { participantDid: string; allocations: Record<string, number>; updatedAt: number }>;
budgetTotalAmount: number;
} }
// ── Schema registration ── // ── Schema registration ──
@ -50,12 +55,12 @@ export interface FlowsDoc {
export const flowsSchema: DocSchema<FlowsDoc> = { export const flowsSchema: DocSchema<FlowsDoc> = {
module: 'flows', module: 'flows',
collection: 'data', collection: 'data',
version: 3, version: 4,
init: (): FlowsDoc => ({ init: (): FlowsDoc => ({
meta: { meta: {
module: 'flows', module: 'flows',
collection: 'data', collection: 'data',
version: 3, version: 4,
spaceSlug: '', spaceSlug: '',
createdAt: Date.now(), createdAt: Date.now(),
}, },
@ -64,13 +69,19 @@ export const flowsSchema: DocSchema<FlowsDoc> = {
activeFlowId: '', activeFlowId: '',
mortgagePositions: {}, mortgagePositions: {},
reinvestmentPositions: {}, reinvestmentPositions: {},
budgetSegments: {},
budgetAllocations: {},
budgetTotalAmount: 0,
}), }),
migrate: (doc: any, _fromVersion: number) => { migrate: (doc: any, _fromVersion: number) => {
if (!doc.canvasFlows) doc.canvasFlows = {}; if (!doc.canvasFlows) doc.canvasFlows = {};
if (!doc.activeFlowId) doc.activeFlowId = ''; if (!doc.activeFlowId) doc.activeFlowId = '';
if (!doc.mortgagePositions) doc.mortgagePositions = {}; if (!doc.mortgagePositions) doc.mortgagePositions = {};
if (!doc.reinvestmentPositions) doc.reinvestmentPositions = {}; 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; return doc;
}, },
}; };