From e4a0ccd80bf42b8377462f0a1a9a2910fcc666de Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 5 Apr 2026 15:13:54 -0400 Subject: [PATCH] feat(rtime): add gas tank fuel gauge and mycelial auto-connect suggestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace thin 4px progress bar on task nodes with a prominent 12px fuel gauge showing committed (green) + proposed (amber) hours vs total needed. When an orb is dropped on open canvas, auto-find nearest unfulfilled task and show a pulsing preview wire with confirm/dismiss buttons — human approval required before creating the connection. Co-Authored-By: Claude Opus 4.6 --- lib/folk-exchange-node.ts | 229 +++++++++++++++++- modules/rexchange/exchange-reputation.ts | 19 +- modules/rexchange/exchange-routes.ts | 187 +++++++++++++- modules/rexchange/exchange-settlement.ts | 56 ++++- modules/rexchange/landing.ts | 59 +++++ modules/rexchange/mod.ts | 103 +++++++- modules/rexchange/schemas.ts | 64 +++++ modules/rtime/components/folk-timebank-app.ts | 201 +++++++++++++-- 8 files changed, 880 insertions(+), 38 deletions(-) diff --git a/lib/folk-exchange-node.ts b/lib/folk-exchange-node.ts index 490b148..6df1941 100644 --- a/lib/folk-exchange-node.ts +++ b/lib/folk-exchange-node.ts @@ -13,6 +13,8 @@ import { getModuleApiBase } from '../shared/url-helpers'; const BUY_COLOR = '#10b981'; const SELL_COLOR = '#f59e0b'; +const LP_COLOR_LEFT = '#10b981'; +const LP_COLOR_RIGHT = '#f59e0b'; const MATCH_ARC_COLOR = '#60a5fa'; const BG_COLOR = '#0f172a'; const TEXT_COLOR = '#e2e8f0'; @@ -40,6 +42,32 @@ interface OrderIntent { status: string; } +interface LpPosition { + id: string; + creatorName: string; + tokenId: string; + fiatCurrency: string; + tokenRemaining: number; + fiatRemaining: number; + spreadBps: number; + feesEarnedToken: number; + feesEarnedFiat: number; + tradesMatched: number; + status: string; +} + +interface LpOrb { + position: LpPosition; + x: number; + y: number; + radius: number; + vx: number; + vy: number; + phase: number; + opacity: number; + hoverT: number; +} + interface Orb { intent: OrderIntent; x: number; @@ -61,11 +89,14 @@ export class FolkExchangeNode extends FolkShape { private _canvas: HTMLCanvasElement | null = null; private _ctx: CanvasRenderingContext2D | null = null; private _orbs: Orb[] = []; + private _lpOrbs: LpOrb[] = []; private _hovered: Orb | null = null; + private _hoveredLp: LpOrb | null = null; private _animFrame = 0; private _pollTimer = 0; private _spaceSlug = 'demo'; private _intents: OrderIntent[] = []; + private _lpPositions: LpPosition[] = []; override connectedCallback() { super.connectedCallback(); @@ -105,6 +136,9 @@ export class FolkExchangeNode extends FolkShape { Sell + + LP + Matched @@ -119,7 +153,7 @@ export class FolkExchangeNode extends FolkShape { shadow.appendChild(wrapper); this._canvas.addEventListener('mousemove', (e) => this._onMouseMove(e)); - this._canvas.addEventListener('mouseleave', () => { this._hovered = null; }); + this._canvas.addEventListener('mouseleave', () => { this._hovered = null; this._hoveredLp = null; }); this._resizeCanvas(); new ResizeObserver(() => this._resizeCanvas()).observe(wrapper); @@ -139,14 +173,23 @@ export class FolkExchangeNode extends FolkShape { private async _fetchIntents() { try { const base = getModuleApiBase('rexchange'); - const res = await fetch(`${base}/api/exchange/intents`); - if (!res.ok) return; - const data = await res.json() as { intents: OrderIntent[] }; - this._intents = data.intents || []; - this._syncOrbs(); + const [intentsRes, poolsRes] = await Promise.all([ + fetch(`${base}/api/exchange/intents`), + fetch(`${base}/api/exchange/pools`), + ]); + if (intentsRes.ok) { + const data = await intentsRes.json() as { intents: OrderIntent[] }; + this._intents = data.intents || []; + this._syncOrbs(); + } + if (poolsRes.ok) { + const data = await poolsRes.json() as { positions: LpPosition[] }; + this._lpPositions = data.positions || []; + this._syncLpOrbs(); + } const counter = this.shadowRoot?.querySelector('#intent-count'); - if (counter) counter.textContent = `${this._intents.length} intents`; + if (counter) counter.textContent = `${this._intents.length} intents, ${this._lpPositions.length} pools`; } catch { /* silent */ } } @@ -185,6 +228,38 @@ export class FolkExchangeNode extends FolkShape { } } + private _syncLpOrbs() { + const rect = this._canvas?.getBoundingClientRect(); + if (!rect) return; + const w = rect.width; + const h = rect.height; + const existingIds = new Set(this._lpOrbs.map(o => o.position.id)); + const newIds = new Set(this._lpPositions.map(p => p.id)); + + // Remove stale + this._lpOrbs = this._lpOrbs.filter(o => newIds.has(o.position.id)); + + // Add new LP orbs — centered on the divider + for (const pos of this._lpPositions) { + if (existingIds.has(pos.id)) continue; + + const totalDepth = (pos.tokenRemaining / 1_000_000) + pos.fiatRemaining; + const radius = Math.max(16, Math.min(36, 16 + Math.sqrt(totalDepth) * 1.5)); + + this._lpOrbs.push({ + position: pos, + x: w * 0.5 + (Math.random() - 0.5) * 20, + y: 60 + Math.random() * (h - 120), + radius, + vx: (Math.random() - 0.5) * 0.15, + vy: (Math.random() - 0.5) * 0.15, + phase: Math.random() * Math.PI * 2, + opacity: 0, + hoverT: 0, + }); + } + } + private _onMouseMove(e: MouseEvent) { const rect = this._canvas?.getBoundingClientRect(); if (!rect) return; @@ -192,14 +267,26 @@ export class FolkExchangeNode extends FolkShape { const my = e.clientY - rect.top; this._hovered = null; - for (const orb of this._orbs) { + this._hoveredLp = null; + + // Check LP orbs first (they're on top) + for (const orb of this._lpOrbs) { const dx = mx - orb.x, dy = my - orb.y; if (dx * dx + dy * dy < orb.radius * orb.radius) { - this._hovered = orb; + this._hoveredLp = orb; break; } } - this._canvas!.style.cursor = this._hovered ? 'pointer' : 'default'; + if (!this._hoveredLp) { + for (const orb of this._orbs) { + const dx = mx - orb.x, dy = my - orb.y; + if (dx * dx + dy * dy < orb.radius * orb.radius) { + this._hovered = orb; + break; + } + } + } + this._canvas!.style.cursor = (this._hovered || this._hoveredLp) ? 'pointer' : 'default'; } private _animate() { @@ -240,6 +327,32 @@ export class FolkExchangeNode extends FolkShape { orb.hoverT += ((isH ? 1 : 0) - orb.hoverT) * 0.12; if (orb.opacity < 1) orb.opacity = Math.min(1, orb.opacity + 0.03); } + + // Update LP orbs — constrained to straddle the divider + for (const orb of this._lpOrbs) { + orb.phase += 0.004; + orb.vx += Math.sin(orb.phase) * 0.001; + orb.vy += Math.cos(orb.phase * 0.7 + 1.5) * 0.001; + orb.vx *= 0.99; + orb.vy *= 0.99; + orb.x += orb.vx; + orb.y += orb.vy; + + // Constrain to center (spanning divider) + const minX = w * 0.5 - orb.radius * 1.5; + const maxX = w * 0.5 + orb.radius * 1.5; + const minY = 40 + orb.radius; + const maxY = h - 40 - orb.radius; + + if (orb.x < minX) { orb.x = minX; orb.vx *= -0.5; } + if (orb.x > maxX) { orb.x = maxX; orb.vx *= -0.5; } + if (orb.y < minY) { orb.y = minY; orb.vy *= -0.5; } + if (orb.y > maxY) { orb.y = maxY; orb.vy *= -0.5; } + + const isH = this._hoveredLp === orb; + orb.hoverT += ((isH ? 1 : 0) - orb.hoverT) * 0.12; + if (orb.opacity < 1) orb.opacity = Math.min(1, orb.opacity + 0.03); + } } private _draw() { @@ -328,6 +441,102 @@ export class FolkExchangeNode extends FolkShape { ctx.restore(); } + // Draw LP orbs (on the divider) + for (const orb of this._lpOrbs) { + if (orb.opacity < 0.01) continue; + ctx.save(); + ctx.globalAlpha = orb.opacity; + + if (orb.hoverT > 0.05) { + ctx.shadowColor = '#a78bfa'; + ctx.shadowBlur = 24 * orb.hoverT; + } + + const r = orb.radius * (1 + orb.hoverT * 0.15); + + // Outer glow — dual gradient + ctx.beginPath(); + ctx.arc(orb.x, orb.y, r, 0, Math.PI * 2); + const outerGrad = ctx.createLinearGradient(orb.x - r, orb.y, orb.x + r, orb.y); + outerGrad.addColorStop(0, LP_COLOR_LEFT + '20'); + outerGrad.addColorStop(1, LP_COLOR_RIGHT + '20'); + ctx.fillStyle = outerGrad; + ctx.fill(); + + // Inner circle — dual-colored gradient (green left, amber right) + ctx.beginPath(); + ctx.arc(orb.x, orb.y, r * 0.82, 0, Math.PI * 2); + const innerGrad = ctx.createLinearGradient(orb.x - r, orb.y, orb.x + r, orb.y); + innerGrad.addColorStop(0, LP_COLOR_LEFT); + innerGrad.addColorStop(0.5, '#34d399'); + innerGrad.addColorStop(1, LP_COLOR_RIGHT); + ctx.fillStyle = innerGrad; + ctx.fill(); + ctx.shadowBlur = 0; + + // Border gradient + ctx.strokeStyle = '#a78bfa'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Token icon + ctx.font = `${Math.max(10, r * 0.45)}px system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#fff'; + const icon = TOKEN_ICONS[orb.position.tokenId] || '$'; + ctx.fillText(icon, orb.x, orb.y); + + // LP badge dot + ctx.beginPath(); + ctx.arc(orb.x + r * 0.65, orb.y - r * 0.65, 5, 0, Math.PI * 2); + ctx.fillStyle = '#8b5cf6'; + ctx.fill(); + ctx.strokeStyle = '#0f172a'; + ctx.lineWidth = 1; + ctx.stroke(); + + ctx.restore(); + } + + // Hover tooltip — LP positions + if (this._hoveredLp) { + const orb = this._hoveredLp; + const p = orb.position; + const tokenDepth = (p.tokenRemaining / 1_000_000).toFixed(2); + const fiatSymbol = p.fiatCurrency === 'USD' ? '$' : p.fiatCurrency === 'EUR' ? '€' : p.fiatCurrency === 'GBP' ? '£' : ''; + const earned = `${fiatSymbol}${p.feesEarnedFiat.toFixed(2)}`; + const lines = [ + `${p.creatorName} (LP)`, + `${p.tokenId}/${p.fiatCurrency}`, + `Depth: ${tokenDepth} / ${fiatSymbol}${p.fiatRemaining.toFixed(2)}`, + `Spread: ${p.spreadBps}bps`, + `Earned: ${earned} | ${p.tradesMatched} trades`, + ]; + + const tooltipX = orb.x + orb.radius + 12; + const tooltipY = Math.max(50, Math.min(h - 100, orb.y - 40)); + + ctx.save(); + ctx.fillStyle = '#1e293bee'; + const tw = 180; + const th = lines.length * 16 + 12; + const tx = tooltipX + tw > w ? orb.x - orb.radius - tw - 12 : tooltipX; + ctx.beginPath(); + ctx.roundRect(tx, tooltipY, tw, th, 6); + ctx.fill(); + + ctx.font = '600 11px system-ui, sans-serif'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + for (let l = 0; l < lines.length; l++) { + ctx.fillStyle = l === 0 ? '#a78bfa' : MUTED_COLOR; + ctx.font = l === 0 ? '600 11px system-ui, sans-serif' : '11px system-ui, sans-serif'; + ctx.fillText(lines[l], tx + 8, tooltipY + 6 + l * 16); + } + ctx.restore(); + } + // Hover tooltip if (this._hovered) { const orb = this._hovered; diff --git a/modules/rexchange/exchange-reputation.ts b/modules/rexchange/exchange-reputation.ts index da12c40..99e2b77 100644 --- a/modules/rexchange/exchange-reputation.ts +++ b/modules/rexchange/exchange-reputation.ts @@ -10,7 +10,7 @@ * top_trader — ≥ $10k equivalent volume */ -import type { ExchangeReputationRecord, ExchangeReputationDoc, ExchangeTradesDoc } from './schemas'; +import type { ExchangeReputationRecord, ExchangeReputationDoc, ExchangeTradesDoc, LiquidityPosition } from './schemas'; export const DEFAULT_REPUTATION: ExchangeReputationRecord = { did: '', @@ -55,10 +55,12 @@ export function calculateScore(rec: ExchangeReputationRecord): number { /** * Compute badges based on reputation stats. + * LP badge awarded for standing orders OR active LP positions. */ export function computeBadges( rec: ExchangeReputationRecord, hasStandingOrders: boolean, + hasActiveLpPositions: boolean = false, ): string[] { const badges: string[] = []; @@ -66,7 +68,7 @@ export function computeBadges( badges.push('verified_seller'); } - if (hasStandingOrders) { + if (hasStandingOrders || hasActiveLpPositions) { badges.push('liquidity_provider'); } @@ -122,3 +124,16 @@ export function hasStandingOrders( i => i.creatorDid === did && i.isStandingOrder && i.status === 'active', ); } + +/** + * Check if a DID has active liquidity pool positions. + */ +export function hasActiveLpPositions( + did: string, + poolsDoc: { positions: Record } | null | undefined, +): boolean { + if (!poolsDoc) return false; + return Object.values(poolsDoc.positions).some( + p => p.creatorDid === did && p.status === 'active', + ); +} diff --git a/modules/rexchange/exchange-routes.ts b/modules/rexchange/exchange-routes.ts index c157d2a..be30c0b 100644 --- a/modules/rexchange/exchange-routes.ts +++ b/modules/rexchange/exchange-routes.ts @@ -7,12 +7,13 @@ import * as Automerge from '@automerge/automerge'; import { verifyToken, extractToken } from '../../server/auth'; import type { SyncServer } from '../../server/local-first/sync-server'; import { - exchangeIntentsDocId, exchangeTradesDocId, exchangeReputationDocId, - exchangeIntentsSchema, exchangeTradesSchema, exchangeReputationSchema, + exchangeIntentsDocId, exchangeTradesDocId, exchangeReputationDocId, exchangePoolsDocId, + exchangeIntentsSchema, exchangeTradesSchema, exchangeReputationSchema, exchangePoolsSchema, } from './schemas'; import type { - ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc, + ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc, ExchangePoolsDoc, ExchangeIntent, ExchangeTrade, ExchangeSide, TokenId, FiatCurrency, RateType, + LiquidityPosition, } from './schemas'; import { solveExchange } from './exchange-solver'; import { lockEscrow, releaseEscrow, reverseEscrow, resolveDispute, sweepTimeouts } from './exchange-settlement'; @@ -80,6 +81,21 @@ export function createExchangeRoutes(getSyncServer: () => SyncServer | null) { return doc; } + function ensurePoolsDoc(space: string): ExchangePoolsDoc { + const syncServer = ss(); + const docId = exchangePoolsDocId(space); + let doc = syncServer.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init exchange pools', (d) => { + const init = exchangePoolsSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + syncServer.setDoc(docId, doc); + } + return doc; + } + // ── POST /api/exchange/intent — Create buy/sell intent ── routes.post('/api/exchange/intent', async (c) => { @@ -487,6 +503,171 @@ export function createExchangeRoutes(getSyncServer: () => SyncServer | null) { return c.json(updated.trades[id]); }); + // ── POST /api/exchange/pool — Add Liquidity ── + + routes.post('/api/exchange/pool', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + const body = await c.req.json(); + const { tokenId, fiatCurrency, tokenAmount, fiatAmount, spreadBps, paymentMethods } = body; + + if (!VALID_TOKENS.includes(tokenId)) return c.json({ error: `tokenId must be: ${VALID_TOKENS.join(', ')}` }, 400); + if (!VALID_FIATS.includes(fiatCurrency)) return c.json({ error: `fiatCurrency must be: ${VALID_FIATS.join(', ')}` }, 400); + if (typeof tokenAmount !== 'number' || tokenAmount <= 0) return c.json({ error: 'tokenAmount must be positive' }, 400); + if (typeof fiatAmount !== 'number' || fiatAmount <= 0) return c.json({ error: 'fiatAmount must be positive' }, 400); + if (typeof spreadBps !== 'number' || spreadBps < 0 || spreadBps > 1000) return c.json({ error: 'spreadBps must be 0-1000' }, 400); + if (!paymentMethods?.length) return c.json({ error: 'At least one payment method required' }, 400); + + const positionId = crypto.randomUUID(); + const buyIntentId = crypto.randomUUID(); + const sellIntentId = crypto.randomUUID(); + const now = Date.now(); + const creatorDid = claims.did as string; + const creatorName = claims.username as string || 'Unknown'; + + // Create paired standing orders — buy at market-spreadBps, sell at market+spreadBps + ensureIntentsDoc(space); + const buyIntent: ExchangeIntent = { + id: buyIntentId, creatorDid, creatorName, + side: 'buy', tokenId, fiatCurrency, + tokenAmountMin: 1_000_000, // 1 token minimum per fill + tokenAmountMax: tokenAmount, + rateType: 'market_plus_bps', rateMarketBps: spreadBps, + paymentMethods, isStandingOrder: true, autoAccept: true, + allowInstitutionalFallback: false, status: 'active', + createdAt: now, + }; + const sellIntent: ExchangeIntent = { + id: sellIntentId, creatorDid, creatorName, + side: 'sell', tokenId, fiatCurrency, + tokenAmountMin: 1_000_000, + tokenAmountMax: tokenAmount, + rateType: 'market_plus_bps', rateMarketBps: spreadBps, + paymentMethods, isStandingOrder: true, autoAccept: true, + allowInstitutionalFallback: false, status: 'active', + createdAt: now, + }; + + ss().changeDoc(exchangeIntentsDocId(space), 'create LP paired intents', (d) => { + d.intents[buyIntentId] = buyIntent as any; + d.intents[sellIntentId] = sellIntent as any; + }); + + // Create the liquidity position + const position: LiquidityPosition = { + id: positionId, creatorDid, creatorName, + tokenId, fiatCurrency, + tokenCommitted: tokenAmount, tokenRemaining: tokenAmount, + fiatCommitted: fiatAmount, fiatRemaining: fiatAmount, + spreadBps, paymentMethods, + buyIntentId, sellIntentId, + feesEarnedToken: 0, feesEarnedFiat: 0, tradesMatched: 0, + status: 'active', createdAt: now, updatedAt: now, + }; + + ensurePoolsDoc(space); + ss().changeDoc(exchangePoolsDocId(space), 'create liquidity position', (d) => { + d.positions[positionId] = position as any; + }); + + const doc = ss().getDoc(exchangePoolsDocId(space))!; + return c.json(doc.positions[positionId], 201); + }); + + // ── GET /api/exchange/pools — List all active positions ── + + routes.get('/api/exchange/pools', (c) => { + const space = c.req.param('space') || 'demo'; + ensurePoolsDoc(space); + const doc = ss().getDoc(exchangePoolsDocId(space))!; + const positions = Object.values(doc.positions).filter(p => p.status === 'active'); + return c.json({ positions }); + }); + + // ── GET /api/exchange/pools/mine — My positions ── + + routes.get('/api/exchange/pools/mine', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + ensurePoolsDoc(space); + const doc = ss().getDoc(exchangePoolsDocId(space))!; + const positions = Object.values(doc.positions).filter(p => p.creatorDid === claims.did); + return c.json({ positions }); + }); + + // ── PATCH /api/exchange/pool/:id — Update or withdraw ── + + routes.patch('/api/exchange/pool/:id', async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: 'Authentication required' }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: 'Invalid token' }, 401); } + + const space = c.req.param('space') || 'demo'; + const id = c.req.param('id'); + const body = await c.req.json(); + + ensurePoolsDoc(space); + const doc = ss().getDoc(exchangePoolsDocId(space))!; + const position = doc.positions[id]; + if (!position) return c.json({ error: 'Position not found' }, 404); + if (position.creatorDid !== claims.did) return c.json({ error: 'Not your position' }, 403); + + const now = Date.now(); + + // Handle status changes + if (body.status === 'paused' || body.status === 'withdrawn') { + // Cancel linked intents + ensureIntentsDoc(space); + ss().changeDoc(exchangeIntentsDocId(space), `LP ${body.status}: cancel linked intents`, (d) => { + if (d.intents[position.buyIntentId]) d.intents[position.buyIntentId].status = 'cancelled' as any; + if (d.intents[position.sellIntentId]) d.intents[position.sellIntentId].status = 'cancelled' as any; + }); + } + + ss().changeDoc(exchangePoolsDocId(space), 'update liquidity position', (d) => { + const p = d.positions[id]; + if (body.status) p.status = body.status as any; + if (body.spreadBps != null) p.spreadBps = body.spreadBps as any; + if (body.paymentMethods) p.paymentMethods = body.paymentMethods as any; + if (body.tokenCommitted != null) { + const delta = body.tokenCommitted - p.tokenCommitted; + p.tokenCommitted = body.tokenCommitted as any; + p.tokenRemaining = Math.max(0, p.tokenRemaining + delta) as any; + } + if (body.fiatCommitted != null) { + const delta = body.fiatCommitted - p.fiatCommitted; + p.fiatCommitted = body.fiatCommitted as any; + p.fiatRemaining = Math.max(0, p.fiatRemaining + delta) as any; + } + p.updatedAt = now as any; + }); + + // If updating spread or payment methods, also update linked intents + if ((body.spreadBps != null || body.paymentMethods) && body.status !== 'paused' && body.status !== 'withdrawn') { + ensureIntentsDoc(space); + ss().changeDoc(exchangeIntentsDocId(space), 'update LP linked intents', (d) => { + for (const intentId of [position.buyIntentId, position.sellIntentId]) { + const intent = d.intents[intentId]; + if (!intent) continue; + if (body.spreadBps != null) intent.rateMarketBps = body.spreadBps as any; + if (body.paymentMethods) intent.paymentMethods = body.paymentMethods as any; + } + }); + } + + const updated = ss().getDoc(exchangePoolsDocId(space))!; + return c.json(updated.positions[id]); + }); + // ── GET /api/exchange/reputation/:did — Reputation lookup ── routes.get('/api/exchange/reputation/:did', (c) => { diff --git a/modules/rexchange/exchange-settlement.ts b/modules/rexchange/exchange-settlement.ts index 5f875dd..b2ca451 100644 --- a/modules/rexchange/exchange-settlement.ts +++ b/modules/rexchange/exchange-settlement.ts @@ -16,10 +16,10 @@ import { mintTokens, getTokenDoc, getBalance, } from '../../server/token-service'; import { - exchangeIntentsDocId, exchangeTradesDocId, exchangeReputationDocId, + exchangeIntentsDocId, exchangeTradesDocId, exchangeReputationDocId, exchangePoolsDocId, } from './schemas'; import type { - ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc, + ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc, ExchangePoolsDoc, ExchangeTrade, TradeStatus, } from './schemas'; import { updateReputationAfterTrade, calculateScore, computeBadges, hasStandingOrders } from './exchange-reputation'; @@ -146,6 +146,9 @@ export function releaseEscrow( : 0; updateReputationForTrade(trade, confirmTime, syncServer, space); + // Track LP fee accrual if trade matched an LP position + updateLpPositionAfterTrade(trade, syncServer, space); + return { success: true }; } @@ -373,6 +376,55 @@ function updateReputationForTrade( }); } +/** + * After a trade completes, check if either intent belongs to an LP position. + * If so, update fee earnings and remaining amounts on the position. + */ +function updateLpPositionAfterTrade( + trade: ExchangeTrade, + syncServer: SyncServer, + space: string, +): void { + const poolsDocId = exchangePoolsDocId(space); + const poolsDoc = syncServer.getDoc(poolsDocId); + if (!poolsDoc) return; + + // Find position that owns either the buy or sell intent + const position = Object.values(poolsDoc.positions).find( + p => p.buyIntentId === trade.buyIntentId || p.sellIntentId === trade.sellIntentId, + ); + if (!position || position.status !== 'active') return; + + syncServer.changeDoc(poolsDocId, 'LP fee accrual after trade', (d) => { + const p = d.positions[position.id]; + if (!p) return; + + p.tradesMatched = (p.tradesMatched + 1) as any; + p.updatedAt = Date.now() as any; + + // Determine which side was filled + if (position.sellIntentId === trade.sellIntentId) { + // LP sold tokens → decrement tokenRemaining, earn fiat fee + p.tokenRemaining = Math.max(0, p.tokenRemaining - trade.tokenAmount) as any; + // Fee ≈ spread portion of the fiat amount: (spreadBps/10000) × fiatAmount + const fiatFee = (p.spreadBps / 10000) * trade.fiatAmount; + p.feesEarnedFiat = (p.feesEarnedFiat + fiatFee) as any; + } + if (position.buyIntentId === trade.buyIntentId) { + // LP bought tokens → decrement fiatRemaining, earn token fee + p.fiatRemaining = Math.max(0, p.fiatRemaining - trade.fiatAmount) as any; + // Fee ≈ spread portion of the token amount: (spreadBps/10000) × tokenAmount + const tokenFee = Math.round((p.spreadBps / 10000) * trade.tokenAmount); + p.feesEarnedToken = (p.feesEarnedToken + tokenFee) as any; + } + + // If either side depleted, pause the position + if (p.tokenRemaining <= 0 || p.fiatRemaining <= 0) { + p.status = 'paused' as any; + } + }); +} + function updateDisputeLoser( loserDid: string, syncServer: SyncServer, diff --git a/modules/rexchange/landing.ts b/modules/rexchange/landing.ts index dec735c..a4993ca 100644 --- a/modules/rexchange/landing.ts +++ b/modules/rexchange/landing.ts @@ -155,6 +155,65 @@ export function renderLanding(): string { + +
+
+
+ + Passive Income + +

+ Earn the Spread +

+

+ Provide liquidity to popular pairs and earn fees on every trade. + Set it and forget it — your positions work 24/7. +

+
+ +
+
+
+
+ 1 +
+

Pick a Pair

+
+

+ Choose a token/fiat pair like cUSDC/EUR or cUSDC/USD. Popular pairs get more + trade volume and more fee income. +

+
+ +
+
+
+ 2 +
+

Set Amount & Spread

+
+

+ Commit tokens and fiat. Set your spread in basis points — + this is your fee on each trade. Both buy and sell orders are created automatically. +

+
+ +
+
+
+ 3 +
+

Earn Fees

+
+

+ Trades are auto-accepted and settled. You earn the spread on every fill. + Track your earnings, pause, or withdraw any time. +

+
+
+
+
+
diff --git a/modules/rexchange/mod.ts b/modules/rexchange/mod.ts index f36f846..78a58e8 100644 --- a/modules/rexchange/mod.ts +++ b/modules/rexchange/mod.ts @@ -19,12 +19,12 @@ import type { RSpaceModule } from '../../shared/module'; import type { SyncServer } from '../../server/local-first/sync-server'; import { renderLanding } from './landing'; import { - exchangeIntentsSchema, exchangeTradesSchema, exchangeReputationSchema, - exchangeIntentsDocId, exchangeTradesDocId, exchangeReputationDocId, + exchangeIntentsSchema, exchangeTradesSchema, exchangeReputationSchema, exchangePoolsSchema, + exchangeIntentsDocId, exchangeTradesDocId, exchangeReputationDocId, exchangePoolsDocId, } from './schemas'; import type { - ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc, - ExchangeIntent, ExchangeTrade, ExchangeReputationRecord, + ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc, ExchangePoolsDoc, + ExchangeIntent, ExchangeTrade, ExchangeReputationRecord, LiquidityPosition, } from './schemas'; import { createExchangeRoutes, startSolverCron } from './exchange-routes'; @@ -81,6 +81,20 @@ function ensureReputationDoc(space: string): ExchangeReputationDoc { return doc; } +function ensurePoolsDoc(space: string): ExchangePoolsDoc { + const docId = exchangePoolsDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init exchange pools', (d) => { + const init = exchangePoolsSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + // ── Demo seeding ── const DEMO_DIDS = { @@ -195,6 +209,27 @@ const DEMO_TRADES: Omit[] = [ }, ]; +const DEMO_LP_POSITIONS: Omit[] = [ + { + creatorDid: DEMO_DIDS.maya, creatorName: 'Maya', + tokenId: 'cusdc', fiatCurrency: 'USD', + tokenCommitted: 500_000_000, tokenRemaining: 488_000_000, + fiatCommitted: 490, fiatRemaining: 477.50, + spreadBps: 50, paymentMethods: ['Revolut', 'Cash'], + feesEarnedToken: 62_500, feesEarnedFiat: 12.50, + tradesMatched: 8, status: 'active', + }, + { + creatorDid: DEMO_DIDS.bob, creatorName: 'Bob', + tokenId: 'cusdc', fiatCurrency: 'EUR', + tokenCommitted: 300_000_000, tokenRemaining: 285_000_000, + fiatCommitted: 294, fiatRemaining: 285.80, + spreadBps: 30, paymentMethods: ['SEPA'], + feesEarnedToken: 45_000, feesEarnedFiat: 8.20, + tradesMatched: 15, status: 'active', + }, +]; + function seedDemoIfEmpty(space: string = 'demo') { if (!_syncServer) return; const existing = _syncServer.getDoc(exchangeIntentsDocId(space)); @@ -228,7 +263,23 @@ function seedDemoIfEmpty(space: string = 'demo') { } }); - console.log(`[rExchange] Demo data seeded for "${space}": ${DEMO_INTENTS.length} intents, ${DEMO_TRADES.length} trades, ${DEMO_REPUTATION.length} reputation records`); + // Seed LP positions + ensurePoolsDoc(space); + _syncServer.changeDoc(exchangePoolsDocId(space), 'seed LP positions', (d) => { + for (const lp of DEMO_LP_POSITIONS) { + const id = crypto.randomUUID(); + const buyIntentId = crypto.randomUUID(); + const sellIntentId = crypto.randomUUID(); + d.positions[id] = { + id, ...lp, + buyIntentId, sellIntentId, + createdAt: now - 86400_000 * 7, + updatedAt: now - 3600_000, + } as any; + } + }); + + console.log(`[rExchange] Demo data seeded for "${space}": ${DEMO_INTENTS.length} intents, ${DEMO_TRADES.length} trades, ${DEMO_REPUTATION.length} reputation records, ${DEMO_LP_POSITIONS.length} LP positions`); } // ── Server-rendered order book page ── @@ -237,6 +288,7 @@ function renderOrderBook(space: string): string { const intentsDoc = _syncServer?.getDoc(exchangeIntentsDocId(space)); const tradesDoc = _syncServer?.getDoc(exchangeTradesDocId(space)); const repDoc = _syncServer?.getDoc(exchangeReputationDocId(space)); + const poolsDoc = _syncServer?.getDoc(exchangePoolsDocId(space)); const intents = intentsDoc ? Object.values(intentsDoc.intents) : []; const trades = tradesDoc ? Object.values(tradesDoc.trades) : []; @@ -245,6 +297,7 @@ function renderOrderBook(space: string): string { const sellIntents = intents.filter(i => i.status === 'active' && i.side === 'sell'); const activeTrades = trades.filter(t => !['completed', 'cancelled', 'timed_out', 'resolved'].includes(t.status)); const completedTrades = trades.filter(t => t.status === 'completed'); + const activePositions = poolsDoc ? Object.values(poolsDoc.positions).filter(p => p.status === 'active') : []; function fmtAmount(base: number) { return (base / 1_000_000).toFixed(2); @@ -299,6 +352,24 @@ function renderOrderBook(space: string): string { `; } + function poolRow(p: LiquidityPosition) { + const icon = p.tokenId === 'cusdc' ? '💵' : p.tokenId === 'myco' ? '🌱' : '🎮'; + const tokenDepth = fmtAmount(p.tokenRemaining); + const fiatDepth = p.fiatRemaining.toFixed(2); + const fiatSymbol = p.fiatCurrency === 'USD' ? '$' : p.fiatCurrency === 'EUR' ? '€' : p.fiatCurrency === 'GBP' ? '£' : ''; + const earned = p.fiatCurrency === 'USD' ? `$${p.feesEarnedFiat.toFixed(2)}` + : p.fiatCurrency === 'EUR' ? `€${p.feesEarnedFiat.toFixed(2)}` + : `${p.feesEarnedFiat.toFixed(2)} ${p.fiatCurrency}`; + return ` + ${p.creatorName} ${repBadge(p.creatorDid)} + ${icon} ${p.tokenId}/${p.fiatCurrency} + ${tokenDepth} / ${fiatSymbol}${fiatDepth} + ${p.spreadBps}bps + ${earned} + ${p.tradesMatched} + `; + } + return `
@@ -311,6 +382,7 @@ function renderOrderBook(space: string): string {
${buyIntents.length} buys ${sellIntents.length} sells + ${activePositions.length} pools ${activeTrades.length} active ${completedTrades.length} settled
@@ -335,6 +407,25 @@ function renderOrderBook(space: string): string { `}
+ + ${activePositions.length > 0 ? ` +
+

+ 💧 Liquidity Pools +

+ + + + + + + + + + ${activePositions.map(poolRow).join('')} +
ProviderPairDepthSpreadEarnedTrades
+
` : ''} + ${activeTrades.length > 0 ? `
@@ -404,6 +495,7 @@ export const exchangeModule: RSpaceModule = { { pattern: '{space}:rexchange:intents', description: 'Buy/sell intent order book', init: exchangeIntentsSchema.init }, { pattern: '{space}:rexchange:trades', description: 'Active and historical trades', init: exchangeTradesSchema.init }, { pattern: '{space}:rexchange:reputation', description: 'Per-member exchange reputation', init: exchangeReputationSchema.init }, + { pattern: '{space}:rexchange:pools', description: 'Liquidity pool positions', init: exchangePoolsSchema.init }, ], routes, landingPage: renderLanding, @@ -428,5 +520,6 @@ export const exchangeModule: RSpaceModule = { ], onboardingActions: [ { label: 'Post Intent', icon: '💱', description: 'Post a buy or sell intent', type: 'create', href: '/rexchange' }, + { label: 'Add Liquidity', icon: '💧', description: 'Provide liquidity to earn the spread', type: 'create', href: '/rexchange' }, ], }; diff --git a/modules/rexchange/schemas.ts b/modules/rexchange/schemas.ts index 4a42b90..9c52cc4 100644 --- a/modules/rexchange/schemas.ts +++ b/modules/rexchange/schemas.ts @@ -91,6 +91,50 @@ export interface ExchangeTrade { completedAt?: number; } +// ── Liquidity Pools ── + +export type PoolStatus = 'active' | 'paused' | 'withdrawn'; + +export interface LiquidityPosition { + id: string; + creatorDid: string; + creatorName: string; + tokenId: TokenId; + fiatCurrency: FiatCurrency; + // Token side (sellable) + tokenCommitted: number; // base units locked for selling + tokenRemaining: number; // decrements as sells fill + // Fiat side (buyable) + fiatCommitted: number; // fiat amount committed for buying tokens + fiatRemaining: number; // decrements as buys fill + // Spread = the LP's fee (applied as market_plus_bps on both sides) + spreadBps: number; // e.g., 50 = 0.5% each side, 1% round-trip + // Payment + paymentMethods: string[]; + // Linked standing order IDs (created automatically) + buyIntentId: string; + sellIntentId: string; + // Earnings tracking + feesEarnedToken: number; // token base units earned from spread + feesEarnedFiat: number; // fiat earned from spread + tradesMatched: number; + // State + status: PoolStatus; + createdAt: number; + updatedAt: number; +} + +export interface ExchangePoolsDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + positions: Record; +} + // ── Reputation ── export interface ExchangeReputationRecord { @@ -154,6 +198,10 @@ export function exchangeReputationDocId(space: string) { return `${space}:rexchange:reputation` as const; } +export function exchangePoolsDocId(space: string) { + return `${space}:rexchange:pools` as const; +} + // ── Schema registrations ── export const exchangeIntentsSchema: DocSchema = { @@ -203,3 +251,19 @@ export const exchangeReputationSchema: DocSchema = { records: {}, }), }; + +export const exchangePoolsSchema: DocSchema = { + module: 'rexchange', + collection: 'pools', + version: 1, + init: (): ExchangePoolsDoc => ({ + meta: { + module: 'rexchange', + collection: 'pools', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + positions: {}, + }), +}; diff --git a/modules/rtime/components/folk-timebank-app.ts b/modules/rtime/components/folk-timebank-app.ts index f6360cf..a03dc72 100644 --- a/modules/rtime/components/folk-timebank-app.ts +++ b/modules/rtime/components/folk-timebank-app.ts @@ -27,7 +27,8 @@ const EXEC_STEPS: Record n.id === s.fromId); + if (fromNode) { + const cx = fromNode.x + fromNode.w / 2, cy = fromNode.y + fromNode.h / 2; + const pts = hexPoints(cx, cy, fromNode.hexR || HEX_R); + const x1 = pts[1][0], y1 = pts[1][1]; + const skills = Object.keys(s.toNode.data.needs); + const idx = skills.indexOf(s.skill); + const x2 = s.toNode.x; + const y2 = idx >= 0 ? s.toNode.y + TASK_H_BASE + idx * TASK_ROW + TASK_ROW / 2 : s.toNode.y + s.toNode.h / 2; + + // Pulsing dashed preview wire + const preview = ns('path'); + preview.setAttribute('d', bezier(x1, y1, x2, y2)); + preview.setAttribute('stroke', '#38bdf8'); + preview.setAttribute('stroke-width', '2.5'); + preview.setAttribute('stroke-dasharray', '8 4'); + preview.setAttribute('fill', 'none'); + preview.setAttribute('opacity', '0.8'); + const anim = ns('animate'); + anim.setAttribute('attributeName', 'stroke-dashoffset'); + anim.setAttribute('values', '0;24'); + anim.setAttribute('dur', '1s'); + anim.setAttribute('repeatCount', 'indefinite'); + preview.appendChild(anim); + this.connectionsLayer.appendChild(preview); + + // Suggestion label + buttons at midpoint + const mx = (x1 + x2) / 2, my = (y1 + y2) / 2; + const sugLabel = svgText('Connect ' + s.hours + 'h ' + s.skill + '?', mx, my - 14, 11, '#38bdf8', '600', 'middle'); + this.connectionsLayer.appendChild(sugLabel); + + // Confirm button + const confirmBg = ns('rect'); + confirmBg.setAttribute('x', String(mx - 40)); confirmBg.setAttribute('y', String(my - 2)); + confirmBg.setAttribute('width', '36'); confirmBg.setAttribute('height', '20'); + confirmBg.setAttribute('rx', '4'); confirmBg.setAttribute('fill', '#10b981'); + confirmBg.setAttribute('class', 'suggest-confirm-btn'); + confirmBg.style.cursor = 'pointer'; + this.connectionsLayer.appendChild(confirmBg); + const confirmT = svgText('\u2713 Yes', mx - 22, my + 14, 10, '#fff', '700', 'middle'); + confirmT.style.pointerEvents = 'none'; + this.connectionsLayer.appendChild(confirmT); + + // Dismiss button + const dismissBg = ns('rect'); + dismissBg.setAttribute('x', String(mx + 4)); dismissBg.setAttribute('y', String(my - 2)); + dismissBg.setAttribute('width', '36'); dismissBg.setAttribute('height', '20'); + dismissBg.setAttribute('rx', '4'); dismissBg.setAttribute('fill', '#ef4444'); + dismissBg.setAttribute('class', 'suggest-dismiss-btn'); + dismissBg.style.cursor = 'pointer'; + this.connectionsLayer.appendChild(dismissBg); + const dismissT = svgText('\u2717 No', mx + 22, my + 14, 10, '#fff', '700', 'middle'); + dismissT.style.pointerEvents = 'none'; + this.connectionsLayer.appendChild(dismissT); + } + } } private renderNode(node: WeaveNode): SVGGElement { @@ -1356,8 +1420,12 @@ class FolkTimebankApp extends HTMLElement { const t = node.data as TaskData; const skills = Object.keys(t.needs); const totalNeeded = Object.values(t.needs).reduce((a, b) => a + b, 0); - const totalFulfilled = Object.values(t.fulfilled || {}).reduce((a, b) => a + b, 0); - const progress = totalNeeded > 0 ? Math.min(1, totalFulfilled / totalNeeded) : 0; + + // Compute committed / proposed totals across all skills from live wires + const taskWires = this.connections.filter(w => w.to === node.id); + const totalCommitted = taskWires.filter(w => w.status === 'committed').reduce((s, w) => s + w.hours, 0); + const totalProposed = taskWires.filter(w => w.status === 'proposed').reduce((s, w) => s + w.hours, 0); + const funded = totalNeeded > 0 && totalCommitted >= totalNeeded; const ready = this.isTaskReady(node); node.h = node.baseH! + (ready ? EXEC_BTN_H + 8 : 0); @@ -1383,17 +1451,42 @@ class FolkTimebankApp extends HTMLElement { const editPencil = svgText('\u270E', node.w - 10, 18, 11, '#ffffff66', '400', 'middle'); editPencil.style.pointerEvents = 'none'; g.appendChild(editPencil); - g.appendChild(svgText(ready ? 'Ready!' : Math.round(progress * 100) + '%', node.w - 24, 18, 10, '#ffffffcc', '500', 'end')); + const pctText = funded ? 'Funded!' : (ready ? 'Ready!' : Math.round(((totalCommitted + totalProposed) / Math.max(1, totalNeeded)) * 100) + '%'); + g.appendChild(svgText(pctText, node.w - 24, 18, 10, '#ffffffcc', '500', 'end')); - const pbW = node.w - 24; - const pbBg = ns('rect'); - pbBg.setAttribute('x', '12'); pbBg.setAttribute('y', '36'); pbBg.setAttribute('width', String(pbW)); pbBg.setAttribute('height', '4'); - pbBg.setAttribute('rx', '2'); pbBg.setAttribute('fill', '#334155'); - g.appendChild(pbBg); - const pbF = ns('rect'); - pbF.setAttribute('x', '12'); pbF.setAttribute('y', '36'); pbF.setAttribute('width', String(progress * pbW)); pbF.setAttribute('height', '4'); - pbF.setAttribute('rx', '2'); pbF.setAttribute('fill', hCol); - g.appendChild(pbF); + // ── Gas tank fuel gauge ── + const barW = node.w - GAS_TANK_PAD * 2; + const barBg = ns('rect'); + barBg.setAttribute('x', String(GAS_TANK_PAD)); barBg.setAttribute('y', String(GAS_TANK_Y)); + barBg.setAttribute('width', String(barW)); barBg.setAttribute('height', String(GAS_TANK_H)); + barBg.setAttribute('rx', '4'); barBg.setAttribute('fill', '#1e293b'); + g.appendChild(barBg); + if (totalNeeded > 0) { + const cFrac = Math.min(1, totalCommitted / totalNeeded); + const pFrac = Math.min(1 - cFrac, totalProposed / totalNeeded); + if (cFrac > 0) { + const cBar = ns('rect'); + cBar.setAttribute('x', String(GAS_TANK_PAD)); cBar.setAttribute('y', String(GAS_TANK_Y)); + cBar.setAttribute('width', String(cFrac * barW)); cBar.setAttribute('height', String(GAS_TANK_H)); + cBar.setAttribute('rx', '4'); cBar.setAttribute('fill', '#10b981'); + if (funded) cBar.setAttribute('filter', 'url(#glowGreen)'); + g.appendChild(cBar); + } + if (pFrac > 0) { + const pBar = ns('rect'); + pBar.setAttribute('x', String(GAS_TANK_PAD + cFrac * barW)); pBar.setAttribute('y', String(GAS_TANK_Y)); + pBar.setAttribute('width', String(pFrac * barW)); pBar.setAttribute('height', String(GAS_TANK_H)); + pBar.setAttribute('rx', '4'); pBar.setAttribute('fill', '#f59e0b'); + g.appendChild(pBar); + } + } + // Text overlay on bar + const tankLabel = funded + ? 'Funded!' + : totalCommitted + 'c + ' + totalProposed + 'p / ' + totalNeeded + ' hrs'; + const tankText = svgText(tankLabel, node.w / 2, GAS_TANK_Y + GAS_TANK_H / 2 + 4, 9, '#fff', '600', 'middle'); + tankText.style.pointerEvents = 'none'; + g.appendChild(tankText); skills.forEach((skill, i) => { const ry = TASK_H_BASE + i * TASK_ROW; @@ -1490,6 +1583,16 @@ class FolkTimebankApp extends HTMLElement { return; } + // Mycelial suggestion confirm/dismiss + if ((e.target as Element).classList.contains('suggest-confirm-btn')) { + this.acceptSuggestion(); + return; + } + if ((e.target as Element).classList.contains('suggest-dismiss-btn')) { + this.dismissSuggestion(); + return; + } + // Approve/decline buttons on commitment hexagons if ((e.target as Element).classList.contains('approve-btn')) { const connId = (e.target as SVGElement).getAttribute('data-connection-id'); @@ -1768,9 +1871,35 @@ class FolkTimebankApp extends HTMLElement { } } } else { - // Dropped on open canvas — place commitment node without connection - if (!this.weaveNodes.find(n => n.id === 'cn-' + c.id)) { - this.weaveNodes.push(this.mkCommitNode(c, pt.x - NODE_W / 2, pt.y - NODE_H / 2)); + // Dropped on open canvas — find nearest matching task and suggest connection + const matchTask = this.findNearestUnfulfilledTask(pt.x, pt.y, c.skill); + if (matchTask) { + const available = this.availableHours(c.id); + const needed = (matchTask.data.needs[c.skill] || 0) - ((matchTask.data.fulfilled || {})[c.skill] || 0); + const allocate = Math.min(available, Math.max(0, needed)); + if (allocate > 0) { + // Place commitment node offset left of the task's input port + const commitX = matchTask.x - 180; + const skills = Object.keys(matchTask.data.needs); + const skillIdx = skills.indexOf(c.skill); + const portY = matchTask.y + TASK_H_BASE + (skillIdx >= 0 ? skillIdx : 0) * TASK_ROW + TASK_ROW / 2; + const commitY = portY - NODE_H / 2; + const fromId = 'cn-' + c.id; + if (!this.weaveNodes.find(n => n.id === fromId)) { + this.weaveNodes.push(this.mkCommitNode(c, commitX, commitY)); + } + this.pendingSuggestion = { fromId, toNode: matchTask, skill: c.skill, hours: allocate, commitX, commitY }; + } else { + // No hours to allocate — just place the node + if (!this.weaveNodes.find(n => n.id === 'cn-' + c.id)) { + this.weaveNodes.push(this.mkCommitNode(c, pt.x - NODE_W / 2, pt.y - NODE_H / 2)); + } + } + } else { + // No matching task found — place commitment node without connection + if (!this.weaveNodes.find(n => n.id === 'cn-' + c.id)) { + this.weaveNodes.push(this.mkCommitNode(c, pt.x - NODE_W / 2, pt.y - NODE_H / 2)); + } } } @@ -1814,6 +1943,46 @@ class FolkTimebankApp extends HTMLElement { return null; } + /** Find the nearest task node that needs the given skill and isn't fully fulfilled. */ + private findNearestUnfulfilledTask(x: number, y: number, skill: string): WeaveNode | null { + let best: WeaveNode | null = null; + let bestDist = Infinity; + for (const node of this.weaveNodes) { + if (node.type !== 'task') continue; + const t = node.data as TaskData; + const needed = t.needs[skill]; + if (!needed) continue; + const fulfilled = (t.fulfilled || {})[skill] || 0; + if (fulfilled >= needed) continue; + const dx = x - node.x, dy = y - (node.y + node.h / 2); + const dist = dx * dx + dy * dy; + if (dist < bestDist) { bestDist = dist; best = node; } + } + return best; + } + + /** Accept a pending mycelial suggestion — create the wire. */ + private acceptSuggestion() { + const s = this.pendingSuggestion; + if (!s) return; + if (!this.connections.find(w => w.from === s.fromId && w.to === s.toNode.id && w.skill === s.skill)) { + this.connections.push({ from: s.fromId, to: s.toNode.id, skill: s.skill, hours: s.hours, status: 'proposed' }); + if (!s.toNode.data.fulfilled) s.toNode.data.fulfilled = {}; + s.toNode.data.fulfilled[s.skill] = (s.toNode.data.fulfilled[s.skill] || 0) + s.hours; + this.persistConnection(s.fromId, s.toNode.id, s.skill, s.hours); + } + this.pendingSuggestion = null; + this.buildOrbs(); + this.renderAll(); + this.rebuildSidebar(); + } + + /** Dismiss the pending mycelial suggestion. */ + private dismissSuggestion() { + this.pendingSuggestion = null; + this.renderAll(); + } + /** Highlight task nodes that have unfulfilled ports matching the given skill. */ private applySkillHighlights(skill: string) { const groups = this.nodesLayer.querySelectorAll('.task-node');