feat(rbudgets): multiplayer sync, interactive pie chart, flow integration
- Add budget CRUD methods to FlowsLocalFirstClient (saveBudgetAllocation, addBudgetSegment, removeBudgetSegment, setBudgetTotalAmount) - Init local-first client in budget view with real-time onChange sync - extractBudgetState() recomputes collective averages from Automerge doc - Debounced auto-save (1s) via scheduleBudgetSave() on slider/pie changes - Interactive pie chart: click wedges to select, drag boundaries between segments to adjust allocation percentages with angle-to-pct geometry - Selected segment highlighting (scaled wedge, white border, detail panel, slider row highlight, legend/table row click-to-select) - "Apply to Flow" button pushes collective budget into canvas flow as funnel node with spending allocations mapped to outcome nodes - LIVE indicator when WebSocket connected - Falls back to API for demo/unauthenticated users Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2cce369b7b
commit
246b51b2e0
|
|
@ -185,6 +185,15 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
private collectiveAllocations: { segmentId: string; avgPercentage: number; participantCount: number }[] = [];
|
private collectiveAllocations: { segmentId: string; avgPercentage: number; participantCount: number }[] = [];
|
||||||
private budgetTotalAmount = 0;
|
private budgetTotalAmount = 0;
|
||||||
private budgetParticipantCount = 0;
|
private budgetParticipantCount = 0;
|
||||||
|
private selectedBudgetSegment: string | null = null;
|
||||||
|
private _budgetLfcUnsub: (() => void) | null = null;
|
||||||
|
private budgetSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// Pie drag state
|
||||||
|
private _pieDragging = false;
|
||||||
|
private _pieDragBoundaryIdx = 0;
|
||||||
|
private _pieDragStartAngle = 0;
|
||||||
|
private _pieDragStartPcts: number[] = [];
|
||||||
|
|
||||||
// Tour engine
|
// Tour engine
|
||||||
private _tour!: TourEngine;
|
private _tour!: TourEngine;
|
||||||
|
|
@ -223,7 +232,7 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
this.view = viewAttr === "budgets" ? "budgets" : viewAttr === "mortgage" ? "mortgage" : "detail";
|
this.view = viewAttr === "budgets" ? "budgets" : viewAttr === "mortgage" ? "mortgage" : "detail";
|
||||||
|
|
||||||
if (this.view === "budgets") {
|
if (this.view === "budgets") {
|
||||||
this.loadBudgetData();
|
this.initBudgetView();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -351,7 +360,10 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
this._offlineUnsub = null;
|
this._offlineUnsub = null;
|
||||||
this._lfcUnsub?.();
|
this._lfcUnsub?.();
|
||||||
this._lfcUnsub = null;
|
this._lfcUnsub = null;
|
||||||
|
this._budgetLfcUnsub?.();
|
||||||
|
this._budgetLfcUnsub = null;
|
||||||
if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; }
|
if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; }
|
||||||
|
if (this.budgetSaveTimer) { clearTimeout(this.budgetSaveTimer); this.budgetSaveTimer = null; }
|
||||||
this.localFirstClient?.disconnect();
|
this.localFirstClient?.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5496,6 +5508,7 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
const segId = (slider as HTMLElement).dataset.budgetSlider!;
|
const segId = (slider as HTMLElement).dataset.budgetSlider!;
|
||||||
this.myAllocation[segId] = parseInt((e.target as HTMLInputElement).value) || 0;
|
this.myAllocation[segId] = parseInt((e.target as HTMLInputElement).value) || 0;
|
||||||
this.normalizeBudgetSliders(segId);
|
this.normalizeBudgetSliders(segId);
|
||||||
|
this.scheduleBudgetSave();
|
||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -5517,6 +5530,23 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.shadow.querySelector('[data-budget-action="apply-flow"]')?.addEventListener('click', () => {
|
||||||
|
this.applyBudgetToFlow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Legend/table row click to select segment
|
||||||
|
this.shadow.querySelectorAll('[data-pie-legend]').forEach((el) => {
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
// Don't intercept remove button clicks
|
||||||
|
if ((e.target as HTMLElement).dataset.budgetRemove) return;
|
||||||
|
const segId = (el as HTMLElement).dataset.pieLegend!;
|
||||||
|
this.selectedBudgetSegment = this.selectedBudgetSegment === segId ? null : segId;
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.attachPieListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5587,6 +5617,205 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
|
|
||||||
// ─── Budget tab ─────────────────────────────────────────
|
// ─── Budget tab ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private async initBudgetView() {
|
||||||
|
if (!this.isDemo && isAuthenticated()) {
|
||||||
|
// Multiplayer: init local-first client and sync budget state
|
||||||
|
try {
|
||||||
|
this.localFirstClient = new FlowsLocalFirstClient(this.space);
|
||||||
|
await this.localFirstClient.init();
|
||||||
|
await this.localFirstClient.subscribe();
|
||||||
|
|
||||||
|
this._budgetLfcUnsub = this.localFirstClient.onChange((doc) => {
|
||||||
|
this.extractBudgetState(doc);
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial extraction from doc
|
||||||
|
const doc = this.localFirstClient.getFlows();
|
||||||
|
if (doc) this.extractBudgetState(doc);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[rBudgets] Local-first init failed, falling back to API:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to API / demo data if no segments loaded from local-first
|
||||||
|
if (this.budgetSegments.length === 0) {
|
||||||
|
await this.loadBudgetData();
|
||||||
|
} else {
|
||||||
|
this.loading = false;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractBudgetState(doc: FlowsDoc) {
|
||||||
|
// Extract segments
|
||||||
|
const segments: BudgetSegment[] = [];
|
||||||
|
if (doc.budgetSegments) {
|
||||||
|
for (const [id, seg] of Object.entries(doc.budgetSegments)) {
|
||||||
|
segments.push({ id, name: seg.name, color: seg.color, createdBy: seg.createdBy });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (segments.length > 0) this.budgetSegments = segments;
|
||||||
|
|
||||||
|
// Extract total amount
|
||||||
|
if (doc.budgetTotalAmount) this.budgetTotalAmount = doc.budgetTotalAmount;
|
||||||
|
|
||||||
|
// Extract allocations and compute collective averages
|
||||||
|
if (doc.budgetAllocations) {
|
||||||
|
const allAllocations = Object.values(doc.budgetAllocations);
|
||||||
|
this.budgetParticipantCount = allAllocations.length;
|
||||||
|
|
||||||
|
// My allocation
|
||||||
|
const session = getSession();
|
||||||
|
if (session) {
|
||||||
|
const myDid = (session.claims as any).did || session.claims.sub;
|
||||||
|
const myAlloc = allAllocations.find(a => a.participantDid === myDid);
|
||||||
|
if (myAlloc && !this._pieDragging && !this.budgetSaveTimer) {
|
||||||
|
// Only update from remote if not currently editing
|
||||||
|
this.myAllocation = { ...myAlloc.allocations };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collective averages
|
||||||
|
const segIds = this.budgetSegments.map(s => s.id);
|
||||||
|
this.collectiveAllocations = segIds.map(segId => {
|
||||||
|
const values = allAllocations
|
||||||
|
.map(a => a.allocations[segId] || 0)
|
||||||
|
.filter(v => v > 0 || allAllocations.length > 0);
|
||||||
|
const count = allAllocations.length;
|
||||||
|
const avg = count > 0 ? values.reduce((s, v) => s + v, 0) / count : 0;
|
||||||
|
return { segmentId: segId, avgPercentage: avg, participantCount: count };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleBudgetSave() {
|
||||||
|
if (this.budgetSaveTimer) clearTimeout(this.budgetSaveTimer);
|
||||||
|
this.budgetSaveTimer = setTimeout(() => {
|
||||||
|
this.budgetSaveTimer = null;
|
||||||
|
if (this.localFirstClient) {
|
||||||
|
const session = getSession();
|
||||||
|
if (session) {
|
||||||
|
const myDid = (session.claims as any).did || session.claims.sub;
|
||||||
|
this.localFirstClient.saveBudgetAllocation(myDid, this.myAllocation);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: save via API
|
||||||
|
this.saveBudgetAllocation();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyBudgetToFlow() {
|
||||||
|
if (!this.localFirstClient) {
|
||||||
|
alert('Connect to a space to apply budgets to a flow.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectiveData = this.collectiveAllocations.filter(c => c.avgPercentage > 0);
|
||||||
|
if (collectiveData.length === 0) {
|
||||||
|
alert('No collective allocation data to apply.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create active canvas flow
|
||||||
|
let activeId = this.localFirstClient.getActiveFlowId();
|
||||||
|
const flows = this.localFirstClient.listCanvasFlows();
|
||||||
|
|
||||||
|
if (!activeId && flows.length > 0) {
|
||||||
|
activeId = flows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeId) {
|
||||||
|
// Create a new flow for budget
|
||||||
|
const flowId = 'budget-flow-' + Date.now();
|
||||||
|
const newFlow: CanvasFlow = {
|
||||||
|
id: flowId,
|
||||||
|
name: 'Budget Allocation Flow',
|
||||||
|
nodes: [],
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
createdBy: getSession()?.claims?.sub || null,
|
||||||
|
};
|
||||||
|
this.localFirstClient.saveCanvasFlow(newFlow);
|
||||||
|
this.localFirstClient.setActiveFlow(flowId);
|
||||||
|
activeId = flowId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = this.localFirstClient.getCanvasFlow(activeId);
|
||||||
|
if (!flow) return;
|
||||||
|
|
||||||
|
const nodes: FlowNode[] = [...flow.nodes.map(n => ({ ...n, data: { ...n.data } }))];
|
||||||
|
|
||||||
|
// Find or create funnel node for budget pool
|
||||||
|
let funnelNode = nodes.find(n => n.type === 'funnel' && (n.data as FunnelNodeData).label.includes('Budget'));
|
||||||
|
if (!funnelNode) {
|
||||||
|
funnelNode = {
|
||||||
|
id: 'budget-funnel-' + Date.now(),
|
||||||
|
type: 'funnel',
|
||||||
|
position: { x: 400, y: 100 },
|
||||||
|
data: {
|
||||||
|
label: 'Budget Pool',
|
||||||
|
currentValue: this.budgetTotalAmount,
|
||||||
|
minThreshold: this.budgetTotalAmount * 0.2,
|
||||||
|
maxThreshold: this.budgetTotalAmount * 0.8,
|
||||||
|
maxCapacity: this.budgetTotalAmount,
|
||||||
|
inflowRate: 0,
|
||||||
|
overflowAllocations: [],
|
||||||
|
spendingAllocations: [],
|
||||||
|
} as FunnelNodeData,
|
||||||
|
};
|
||||||
|
nodes.push(funnelNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const funnelData = funnelNode.data as FunnelNodeData;
|
||||||
|
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#ec4899', '#14b8a6', '#f97316'];
|
||||||
|
|
||||||
|
// Create/update outcome nodes for each segment + build spending allocations
|
||||||
|
const spendingAllocations: SpendingAllocation[] = [];
|
||||||
|
let outcomeX = 200;
|
||||||
|
|
||||||
|
for (let i = 0; i < collectiveData.length; i++) {
|
||||||
|
const c = collectiveData[i];
|
||||||
|
const seg = this.budgetSegments.find(s => s.id === c.segmentId);
|
||||||
|
if (!seg) continue;
|
||||||
|
|
||||||
|
// Find or create outcome node
|
||||||
|
let outcomeNode = nodes.find(n => n.type === 'outcome' && (n.data as OutcomeNodeData).label === seg.name);
|
||||||
|
if (!outcomeNode) {
|
||||||
|
outcomeNode = {
|
||||||
|
id: 'budget-outcome-' + seg.id,
|
||||||
|
type: 'outcome',
|
||||||
|
position: { x: outcomeX, y: 350 },
|
||||||
|
data: {
|
||||||
|
label: seg.name,
|
||||||
|
description: `Budget allocation for ${seg.name}`,
|
||||||
|
fundingReceived: 0,
|
||||||
|
fundingTarget: Math.round(this.budgetTotalAmount * c.avgPercentage / 100),
|
||||||
|
status: 'not-started',
|
||||||
|
} as OutcomeNodeData,
|
||||||
|
};
|
||||||
|
nodes.push(outcomeNode);
|
||||||
|
} else {
|
||||||
|
// Update target
|
||||||
|
(outcomeNode.data as OutcomeNodeData).fundingTarget = Math.round(this.budgetTotalAmount * c.avgPercentage / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
spendingAllocations.push({
|
||||||
|
targetId: outcomeNode.id,
|
||||||
|
percentage: c.avgPercentage,
|
||||||
|
color: seg.color || colors[i % colors.length],
|
||||||
|
});
|
||||||
|
|
||||||
|
outcomeX += 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
funnelData.spendingAllocations = spendingAllocations;
|
||||||
|
this.localFirstClient.updateFlowNodes(activeId, nodes);
|
||||||
|
|
||||||
|
alert(`Applied ${collectiveData.length} budget segments to flow "${flow.name}". Switch to canvas view to see the result.`);
|
||||||
|
}
|
||||||
|
|
||||||
private async loadBudgetData() {
|
private async loadBudgetData() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.render();
|
this.render();
|
||||||
|
|
@ -5650,6 +5879,13 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
|
if (this.localFirstClient) {
|
||||||
|
const myDid = (session.claims as any).did || session.claims.sub;
|
||||||
|
this.localFirstClient.saveBudgetAllocation(myDid, this.myAllocation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API fallback
|
||||||
const base = this.getApiBase();
|
const base = this.getApiBase();
|
||||||
try {
|
try {
|
||||||
await fetch(`${base}/api/budgets/allocate`, {
|
await fetch(`${base}/api/budgets/allocate`, {
|
||||||
|
|
@ -5657,7 +5893,6 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
|
||||||
body: JSON.stringify({ space: this.space, allocations: this.myAllocation }),
|
body: JSON.stringify({ space: this.space, allocations: this.myAllocation }),
|
||||||
});
|
});
|
||||||
// Reload to get updated collective
|
|
||||||
await this.loadBudgetData();
|
await this.loadBudgetData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[rBudgets] Save failed:', err);
|
console.error('[rBudgets] Save failed:', err);
|
||||||
|
|
@ -5665,13 +5900,21 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async addBudgetSegment() {
|
private async addBudgetSegment() {
|
||||||
const session = getSession();
|
|
||||||
const name = prompt('Segment name:');
|
const name = prompt('Segment name:');
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
||||||
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#ec4899', '#14b8a6', '#f97316'];
|
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#ec4899', '#14b8a6', '#f97316'];
|
||||||
const color = colors[this.budgetSegments.length % colors.length];
|
const color = colors[this.budgetSegments.length % colors.length];
|
||||||
|
const id = 'seg-' + Date.now();
|
||||||
|
|
||||||
|
if (this.localFirstClient) {
|
||||||
|
const session = getSession();
|
||||||
|
this.localFirstClient.addBudgetSegment(id, name, color, session ? ((session.claims as any).did || session.claims.sub) : null);
|
||||||
|
this.myAllocation[id] = 0;
|
||||||
|
return; // onChange will re-render
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getSession();
|
||||||
if (session) {
|
if (session) {
|
||||||
const base = this.getApiBase();
|
const base = this.getApiBase();
|
||||||
try {
|
try {
|
||||||
|
|
@ -5686,15 +5929,21 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local-only fallback for demo
|
// Local-only fallback for demo
|
||||||
const id = 'seg-' + Date.now();
|
|
||||||
this.budgetSegments.push({ id, name, color, createdBy: null });
|
this.budgetSegments.push({ id, name, color, createdBy: null });
|
||||||
this.myAllocation[id] = 0;
|
this.myAllocation[id] = 0;
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async removeBudgetSegment(segmentId: string) {
|
private async removeBudgetSegment(segmentId: string) {
|
||||||
const session = getSession();
|
if (this.localFirstClient) {
|
||||||
|
this.localFirstClient.removeBudgetSegment(segmentId);
|
||||||
|
delete this.myAllocation[segmentId];
|
||||||
|
if (this.selectedBudgetSegment === segmentId) this.selectedBudgetSegment = null;
|
||||||
|
this.normalizeBudgetSliders();
|
||||||
|
return; // onChange will re-render
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getSession();
|
||||||
if (session) {
|
if (session) {
|
||||||
const base = this.getApiBase();
|
const base = this.getApiBase();
|
||||||
try {
|
try {
|
||||||
|
|
@ -5712,6 +5961,7 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
this.budgetSegments = this.budgetSegments.filter((s) => s.id !== segmentId);
|
this.budgetSegments = this.budgetSegments.filter((s) => s.id !== segmentId);
|
||||||
delete this.myAllocation[segmentId];
|
delete this.myAllocation[segmentId];
|
||||||
this.collectiveAllocations = this.collectiveAllocations.filter((c) => c.segmentId !== segmentId);
|
this.collectiveAllocations = this.collectiveAllocations.filter((c) => c.segmentId !== segmentId);
|
||||||
|
if (this.selectedBudgetSegment === segmentId) this.selectedBudgetSegment = null;
|
||||||
this.normalizeBudgetSliders();
|
this.normalizeBudgetSliders();
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
@ -5764,6 +6014,108 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private attachPieListeners() {
|
||||||
|
const svg = this.shadow.getElementById('budget-pie-svg') as SVGSVGElement | null;
|
||||||
|
if (!svg) return;
|
||||||
|
|
||||||
|
const size = svg.viewBox.baseVal.width;
|
||||||
|
const cx = size / 2, cy = size / 2;
|
||||||
|
|
||||||
|
// Click on wedge to select
|
||||||
|
svg.addEventListener('click', (e) => {
|
||||||
|
const target = e.target as SVGElement;
|
||||||
|
const segId = target.getAttribute('data-pie-segment');
|
||||||
|
if (segId) {
|
||||||
|
this.selectedBudgetSegment = this.selectedBudgetSegment === segId ? null : segId;
|
||||||
|
this.render();
|
||||||
|
// Scroll slider into view
|
||||||
|
if (this.selectedBudgetSegment) {
|
||||||
|
const slider = this.shadow.querySelector(`[data-budget-slider="${this.selectedBudgetSegment}"]`) as HTMLElement;
|
||||||
|
slider?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag on boundary to resize adjacent segments
|
||||||
|
svg.addEventListener('pointerdown', (e) => {
|
||||||
|
const target = e.target as SVGElement;
|
||||||
|
const boundaryIdx = target.getAttribute('data-pie-boundary');
|
||||||
|
if (boundaryIdx === null) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
(target as Element).setPointerCapture(e.pointerId);
|
||||||
|
|
||||||
|
this._pieDragging = true;
|
||||||
|
this._pieDragBoundaryIdx = parseInt(boundaryIdx);
|
||||||
|
|
||||||
|
// Store starting angle from pointer position
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const px = (e.clientX - rect.left) * (size / rect.width);
|
||||||
|
const py = (e.clientY - rect.top) * (size / rect.height);
|
||||||
|
this._pieDragStartAngle = Math.atan2(py - cy, px - cx);
|
||||||
|
|
||||||
|
// Store starting percentages for all segments
|
||||||
|
const segIds = this.budgetSegments.map(s => s.id);
|
||||||
|
this._pieDragStartPcts = segIds.map(id => this.myAllocation[id] || 0);
|
||||||
|
|
||||||
|
svg.style.cursor = 'grabbing';
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.addEventListener('pointermove', (e) => {
|
||||||
|
if (!this._pieDragging) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const px = (e.clientX - rect.left) * (size / rect.width);
|
||||||
|
const py = (e.clientY - rect.top) * (size / rect.height);
|
||||||
|
const currentAngle = Math.atan2(py - cy, px - cx);
|
||||||
|
|
||||||
|
let deltaAngle = currentAngle - this._pieDragStartAngle;
|
||||||
|
// Normalize to [-PI, PI]
|
||||||
|
if (deltaAngle > Math.PI) deltaAngle -= 2 * Math.PI;
|
||||||
|
if (deltaAngle < -Math.PI) deltaAngle += 2 * Math.PI;
|
||||||
|
|
||||||
|
const deltaPct = (deltaAngle / (2 * Math.PI)) * 100;
|
||||||
|
|
||||||
|
const idx = this._pieDragBoundaryIdx;
|
||||||
|
const nextIdx = (idx + 1) % this.budgetSegments.length;
|
||||||
|
const segIds = this.budgetSegments.map(s => s.id);
|
||||||
|
|
||||||
|
let newA = this._pieDragStartPcts[idx] + deltaPct;
|
||||||
|
let newB = this._pieDragStartPcts[nextIdx] - deltaPct;
|
||||||
|
|
||||||
|
// Clamp both to [2, 98]
|
||||||
|
if (newA < 2) { newB += (newA - 2); newA = 2; }
|
||||||
|
if (newB < 2) { newA += (newB - 2); newB = 2; }
|
||||||
|
if (newA > 98) { newB -= (newA - 98); newA = 98; }
|
||||||
|
if (newB > 98) { newA -= (newB - 98); newB = 98; }
|
||||||
|
|
||||||
|
this.myAllocation[segIds[idx]] = Math.round(newA);
|
||||||
|
this.myAllocation[segIds[nextIdx]] = Math.round(newB);
|
||||||
|
|
||||||
|
// Normalize total to 100
|
||||||
|
this.normalizeBudgetSliders();
|
||||||
|
|
||||||
|
// Re-render pie chart in-place for responsiveness
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.addEventListener('pointerup', () => {
|
||||||
|
if (!this._pieDragging) return;
|
||||||
|
this._pieDragging = false;
|
||||||
|
svg.style.cursor = '';
|
||||||
|
this.scheduleBudgetSave();
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.addEventListener('lostpointercapture', () => {
|
||||||
|
if (this._pieDragging) {
|
||||||
|
this._pieDragging = false;
|
||||||
|
svg.style.cursor = '';
|
||||||
|
this.scheduleBudgetSave();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private renderPieChart(data: { id: string; label: string; value: number; color: string }[], size: number): string {
|
private renderPieChart(data: { id: string; label: string; value: number; color: string }[], size: number): string {
|
||||||
const total = data.reduce((s, d) => s + d.value, 0);
|
const total = data.reduce((s, d) => s + d.value, 0);
|
||||||
if (total === 0 || data.length === 0) {
|
if (total === 0 || data.length === 0) {
|
||||||
|
|
@ -5777,8 +6129,11 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
let currentAngle = -Math.PI / 2; // Start at top
|
let currentAngle = -Math.PI / 2; // Start at top
|
||||||
const paths: string[] = [];
|
const paths: string[] = [];
|
||||||
const labels: string[] = [];
|
const labels: string[] = [];
|
||||||
|
const boundaries: string[] = [];
|
||||||
|
const boundaryAngles: number[] = []; // Angles at each boundary
|
||||||
|
|
||||||
for (const d of data) {
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const d = data[i];
|
||||||
const pct = d.value / total;
|
const pct = d.value / total;
|
||||||
if (pct <= 0) continue;
|
if (pct <= 0) continue;
|
||||||
const angle = pct * Math.PI * 2;
|
const angle = pct * Math.PI * 2;
|
||||||
|
|
@ -5787,8 +6142,10 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
const x2 = cx + r * Math.cos(currentAngle + angle);
|
const x2 = cx + r * Math.cos(currentAngle + angle);
|
||||||
const y2 = cy + r * Math.sin(currentAngle + angle);
|
const y2 = cy + r * Math.sin(currentAngle + angle);
|
||||||
const largeArc = angle > Math.PI ? 1 : 0;
|
const largeArc = angle > Math.PI ? 1 : 0;
|
||||||
|
const isSelected = this.selectedBudgetSegment === d.id;
|
||||||
|
const scale = isSelected ? 'transform="scale(1.04)" transform-origin="' + cx + ' ' + cy + '"' : '';
|
||||||
|
|
||||||
paths.push(`<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}">
|
paths.push(`<path d="M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z" fill="${d.color}" stroke="${isSelected ? 'white' : 'var(--rs-bg, #0a0a1a)'}" stroke-width="${isSelected ? 3 : 2}" opacity="${isSelected ? 1 : 0.9}" data-pie-segment="${d.id}" style="cursor: pointer; transition: opacity 0.15s;" ${scale}>
|
||||||
<title>${d.label}: ${d.value.toFixed(1)}% ($${Math.round(this.budgetTotalAmount * d.value / 100).toLocaleString()})</title>
|
<title>${d.label}: ${d.value.toFixed(1)}% ($${Math.round(this.budgetTotalAmount * d.value / 100).toLocaleString()})</title>
|
||||||
</path>`);
|
</path>`);
|
||||||
|
|
||||||
|
|
@ -5803,6 +6160,15 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
currentAngle += angle;
|
currentAngle += angle;
|
||||||
|
|
||||||
|
// Store boundary angle between this segment and next (for drag handles)
|
||||||
|
if (data.length > 1) {
|
||||||
|
boundaryAngles.push(currentAngle);
|
||||||
|
const bx = cx + r * Math.cos(currentAngle);
|
||||||
|
const by = cy + r * Math.sin(currentAngle);
|
||||||
|
// Invisible wider line as drag handle
|
||||||
|
boundaries.push(`<line x1="${cx}" y1="${cy}" x2="${bx}" y2="${by}" stroke="transparent" stroke-width="14" data-pie-boundary="${i}" style="cursor: grab;" />`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Center label
|
// Center label
|
||||||
|
|
@ -5811,8 +6177,9 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
<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>`
|
<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));">
|
return `<svg id="budget-pie-svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3)); touch-action: none;">
|
||||||
${paths.join('\n')}
|
${paths.join('\n')}
|
||||||
|
${boundaries.join('\n')}
|
||||||
${labels.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"/>
|
<circle cx="${cx}" cy="${cy}" r="${r * 0.32}" fill="var(--rs-bg, #0a0a1a)" stroke="var(--rs-border, #2a2a3e)" stroke-width="1"/>
|
||||||
${centerLabel}
|
${centerLabel}
|
||||||
|
|
@ -5827,7 +6194,16 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
return { id: c.segmentId, label: seg?.name || c.segmentId, value: c.avgPercentage, color: seg?.color || '#666' };
|
return { id: c.segmentId, label: seg?.name || c.segmentId, value: c.avgPercentage, color: seg?.color || '#666' };
|
||||||
}).filter((d) => d.value > 0);
|
}).filter((d) => d.value > 0);
|
||||||
|
|
||||||
|
const myPieData = this.budgetSegments.map((seg) => ({
|
||||||
|
id: seg.id, label: seg.name, value: this.myAllocation[seg.id] || 0, color: seg.color,
|
||||||
|
})).filter((d) => d.value > 0);
|
||||||
|
|
||||||
const authenticated = isAuthenticated();
|
const authenticated = isAuthenticated();
|
||||||
|
const isLive = this.localFirstClient?.isConnected ?? false;
|
||||||
|
|
||||||
|
// Selected segment detail
|
||||||
|
const selSeg = this.selectedBudgetSegment ? this.budgetSegments.find(s => s.id === this.selectedBudgetSegment) : null;
|
||||||
|
const selCollective = selSeg ? this.collectiveAllocations.find(c => c.segmentId === selSeg.id) : null;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="budget-tab" style="padding: 24px; max-width: 1200px; margin: 0 auto;">
|
<div class="budget-tab" style="padding: 24px; max-width: 1200px; margin: 0 auto;">
|
||||||
|
|
@ -5836,6 +6212,7 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
<a href="/${this.space === 'demo' ? 'demo/' : ''}rflows" style="color: var(--rs-text-secondary); text-decoration: none; font-size: 14px;">← Back to Flows</a>
|
<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>
|
<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="background: rgba(99,102,241,0.15); color: #6366f1; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600;">COLLECTIVE</span>
|
||||||
|
${isLive ? '<span style="display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: #10b981;"><span style="width: 6px; height: 6px; border-radius: 50%; background: #10b981; display: inline-block;"></span>LIVE</span>' : ''}
|
||||||
<span style="margin-left: auto; color: var(--rs-text-secondary); font-size: 13px;">${this.budgetParticipantCount} participant${this.budgetParticipantCount !== 1 ? 's' : ''}</span>
|
<span style="margin-left: auto; color: var(--rs-text-secondary); font-size: 13px;">${this.budgetParticipantCount} participant${this.budgetParticipantCount !== 1 ? 's' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -5843,17 +6220,31 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 24px;">
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 24px;">
|
||||||
<!-- Collective Pie Chart -->
|
<!-- 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;">
|
<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>
|
<h3 style="margin: 0 0 4px; font-size: 16px; color: var(--rs-text-primary); width: 100%;">Collective Allocation</h3>
|
||||||
${this.renderPieChart(pieData, 280)}
|
<p style="margin: 0 0 16px; font-size: 12px; color: var(--rs-text-secondary); width: 100%;">Click a wedge to select. Drag boundaries to adjust your allocation.</p>
|
||||||
|
${this.renderPieChart(myPieData.length > 0 ? myPieData : pieData, 280)}
|
||||||
<!-- Legend -->
|
<!-- Legend -->
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; justify-content: center;">
|
<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; justify-content: center;">
|
||||||
${pieData.map((d) => `
|
${pieData.map((d) => `
|
||||||
<div style="display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--rs-text-secondary);">
|
<div style="display: flex; align-items: center; gap: 4px; font-size: 12px; color: ${this.selectedBudgetSegment === d.id ? 'var(--rs-text-primary)' : 'var(--rs-text-secondary)'}; cursor: pointer; ${this.selectedBudgetSegment === d.id ? 'font-weight: 600;' : ''}" data-pie-legend="${d.id}">
|
||||||
<div style="width: 10px; height: 10px; border-radius: 2px; background: ${d.color};"></div>
|
<div style="width: 10px; height: 10px; border-radius: 2px; background: ${d.color};"></div>
|
||||||
${this.esc(d.label)}: ${d.value.toFixed(1)}%
|
${this.esc(d.label)}: ${d.value.toFixed(1)}%
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
|
${selSeg ? `
|
||||||
|
<div style="margin-top: 12px; padding: 10px 14px; background: var(--rs-bg, #0a0a1a); border-radius: 8px; border: 1px solid ${selSeg.color}44; width: 100%;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 4px;">
|
||||||
|
<div style="width: 10px; height: 10px; border-radius: 2px; background: ${selSeg.color};"></div>
|
||||||
|
<span style="font-size: 14px; font-weight: 600; color: var(--rs-text-primary);">${this.esc(selSeg.name)}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 16px; font-size: 12px; color: var(--rs-text-secondary);">
|
||||||
|
<span>Collective: ${(selCollective?.avgPercentage || 0).toFixed(1)}%</span>
|
||||||
|
<span>Your: ${this.myAllocation[selSeg.id] || 0}%</span>
|
||||||
|
<span>Voters: ${selCollective?.participantCount || 0}</span>
|
||||||
|
${this.budgetTotalAmount > 0 ? `<span>${this.fmtUsd(Math.round(this.budgetTotalAmount * (selCollective?.avgPercentage || 0) / 100))}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- My Allocation Sliders -->
|
<!-- My Allocation Sliders -->
|
||||||
|
|
@ -5867,8 +6258,9 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
${this.budgetSegments.map((seg) => {
|
${this.budgetSegments.map((seg) => {
|
||||||
const val = this.myAllocation[seg.id] || 0;
|
const val = this.myAllocation[seg.id] || 0;
|
||||||
const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * val / 100) : 0;
|
const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * val / 100) : 0;
|
||||||
|
const isSelected = this.selectedBudgetSegment === seg.id;
|
||||||
return `
|
return `
|
||||||
<div style="display: flex; align-items: center; gap: 12px;">
|
<div style="display: flex; align-items: center; gap: 12px; padding: 4px 8px; border-radius: 8px; ${isSelected ? `background: ${seg.color}15; border: 1px solid ${seg.color}44;` : 'border: 1px solid transparent;'} transition: all 0.15s;" data-budget-slider-row="${seg.id}">
|
||||||
<div style="width: 10px; height: 10px; border-radius: 2px; background: ${seg.color}; flex-shrink: 0;"></div>
|
<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>
|
<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}"
|
<input type="range" min="0" max="100" value="${val}" data-budget-slider="${seg.id}"
|
||||||
|
|
@ -5881,9 +6273,10 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
|
|
||||||
<div style="display: flex; gap: 8px; margin-top: 16px;">
|
<div style="display: flex; gap: 8px; margin-top: 16px;">
|
||||||
${authenticated
|
${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>`
|
? `<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;">${this.budgetSaveTimer ? 'Auto-saving...' : '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 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>`
|
||||||
}
|
}
|
||||||
|
${authenticated ? `<button data-budget-action="apply-flow" style="padding: 10px 16px; background: rgba(16,185,129,0.15); color: #10b981; border: 1px solid rgba(16,185,129,0.3); border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600;" title="Push collective budget into active canvas flow">Apply to Flow</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -5900,7 +6293,7 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
const pct = collective?.avgPercentage || 0;
|
const pct = collective?.avgPercentage || 0;
|
||||||
const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * pct / 100) : 0;
|
const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * pct / 100) : 0;
|
||||||
return `
|
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="display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: var(--rs-bg, #0a0a1a); border-radius: 8px; border: 1px solid ${this.selectedBudgetSegment === seg.id ? seg.color : 'var(--rs-border, #2a2a3e)'}; cursor: pointer; transition: border-color 0.15s;" data-pie-legend="${seg.id}">
|
||||||
<div style="width: 12px; height: 12px; border-radius: 3px; background: ${seg.color};"></div>
|
<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: 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>
|
<span style="font-size: 11px; color: var(--rs-text-secondary);">${pct.toFixed(1)}%${amount > 0 ? ` / $${amount.toLocaleString()}` : ''}</span>
|
||||||
|
|
@ -5930,8 +6323,9 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
const pct = collective?.avgPercentage || 0;
|
const pct = collective?.avgPercentage || 0;
|
||||||
const myPct = this.myAllocation[seg.id] || 0;
|
const myPct = this.myAllocation[seg.id] || 0;
|
||||||
const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * pct / 100) : 0;
|
const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * pct / 100) : 0;
|
||||||
|
const isSelected = this.selectedBudgetSegment === seg.id;
|
||||||
return `
|
return `
|
||||||
<tr style="border-bottom: 1px solid var(--rs-border, #2a2a3e);">
|
<tr style="border-bottom: 1px solid var(--rs-border, #2a2a3e); ${isSelected ? `background: ${seg.color}10;` : ''} cursor: pointer;" data-pie-legend="${seg.id}">
|
||||||
<td style="padding: 8px; color: var(--rs-text-primary);">
|
<td style="padding: 8px; color: var(--rs-text-primary);">
|
||||||
<div style="display: flex; align-items: center; gap: 6px;">
|
<div style="display: flex; align-items: center; gap: 6px;">
|
||||||
<div style="width: 8px; height: 8px; border-radius: 2px; background: ${seg.color};"></div>
|
<div style="width: 8px; height: 8px; border-radius: 2px; background: ${seg.color};"></div>
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,54 @@ export class FlowsLocalFirstClient {
|
||||||
return doc?.activeFlowId || '';
|
return doc?.activeFlowId || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Budget CRUD ──
|
||||||
|
|
||||||
|
saveBudgetAllocation(did: string, allocations: Record<string, number>): void {
|
||||||
|
const docId = flowsDocId(this.#space) as DocumentId;
|
||||||
|
this.#sync.change<FlowsDoc>(docId, `Save budget allocation`, (d) => {
|
||||||
|
if (!d.budgetAllocations) d.budgetAllocations = {} as any;
|
||||||
|
d.budgetAllocations[did] = {
|
||||||
|
participantDid: did,
|
||||||
|
allocations,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addBudgetSegment(id: string, name: string, color: string, createdBy: string | null): void {
|
||||||
|
const docId = flowsDocId(this.#space) as DocumentId;
|
||||||
|
this.#sync.change<FlowsDoc>(docId, `Add budget segment ${name}`, (d) => {
|
||||||
|
if (!d.budgetSegments) d.budgetSegments = {} as any;
|
||||||
|
d.budgetSegments[id] = { name, color, createdBy };
|
||||||
|
// Initialize all existing allocations with 0 for new segment
|
||||||
|
if (d.budgetAllocations) {
|
||||||
|
for (const alloc of Object.values(d.budgetAllocations)) {
|
||||||
|
if (!alloc.allocations[id]) alloc.allocations[id] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBudgetSegment(id: string): void {
|
||||||
|
const docId = flowsDocId(this.#space) as DocumentId;
|
||||||
|
this.#sync.change<FlowsDoc>(docId, `Remove budget segment`, (d) => {
|
||||||
|
if (d.budgetSegments?.[id]) delete d.budgetSegments[id];
|
||||||
|
// Clean allocations
|
||||||
|
if (d.budgetAllocations) {
|
||||||
|
for (const alloc of Object.values(d.budgetAllocations)) {
|
||||||
|
if (alloc.allocations[id] !== undefined) delete alloc.allocations[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setBudgetTotalAmount(amount: number): void {
|
||||||
|
const docId = flowsDocId(this.#space) as DocumentId;
|
||||||
|
this.#sync.change<FlowsDoc>(docId, `Set budget total`, (d) => {
|
||||||
|
d.budgetTotalAmount = amount;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
await this.#sync.flush();
|
await this.#sync.flush();
|
||||||
this.#sync.disconnect();
|
this.#sync.disconnect();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue