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:
parent
cab80f30e7
commit
af43e98812
|
|
@ -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<string, number> = {};
|
||||
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 `<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;">← 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">×</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 ───────────────────────────────────────
|
||||
|
||||
private async loadMortgageData() {
|
||||
|
|
|
|||
|
|
@ -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<string, number>; // segmentId → percentage (0-100)
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
// ─── Port definitions ─────────────────────────────────
|
||||
|
||||
export type PortDirection = "in" | "out";
|
||||
|
|
|
|||
|
|
@ -62,6 +62,16 @@ function ensureDoc(space: string): FlowsDoc {
|
|||
});
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -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<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 ────────────────────────────────────────
|
||||
|
||||
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
|
||||
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<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 = {
|
||||
|
|
@ -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." },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<string, MortgagePosition>;
|
||||
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 ──
|
||||
|
|
@ -50,12 +55,12 @@ export interface FlowsDoc {
|
|||
export const flowsSchema: DocSchema<FlowsDoc> = {
|
||||
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<FlowsDoc> = {
|
|||
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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue