From b5c6477f47c3e48e5b6cc7052bbd6024b82be96e Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 30 Mar 2026 23:42:49 -0700 Subject: [PATCH] feat(rtime): add timebank commitment pool & weaving dashboard rApp Port hcc-mem-staging SPA into rSpace as the rTime module. Canvas-based commitment pool with physics orbs, SVG weaving editor with hex nodes and bezier wires, execution panel, and optional Cyclos timebank proxy. Automerge CRDT persistence, demo seeding, and full landing page explaining community-based ledgers and emergent collaboration. Co-Authored-By: Claude Opus 4.6 --- modules/rtime/components/folk-timebank-app.ts | 1651 +++++++++++++++++ modules/rtime/components/rtime.css | 7 + modules/rtime/landing.ts | 296 +++ modules/rtime/mod.ts | 418 +++++ modules/rtime/schemas.ts | 138 ++ server/index.ts | 2 + server/shell.ts | 1 + vite.config.ts | 27 + 8 files changed, 2540 insertions(+) create mode 100644 modules/rtime/components/folk-timebank-app.ts create mode 100644 modules/rtime/components/rtime.css create mode 100644 modules/rtime/landing.ts create mode 100644 modules/rtime/mod.ts create mode 100644 modules/rtime/schemas.ts diff --git a/modules/rtime/components/folk-timebank-app.ts b/modules/rtime/components/folk-timebank-app.ts new file mode 100644 index 0000000..af7a826 --- /dev/null +++ b/modules/rtime/components/folk-timebank-app.ts @@ -0,0 +1,1651 @@ +/** + * folk-timebank-app — Timebank commitment pool & weaving dashboard. + * + * Port of hcc-mem-staging/html/index.html into a shadow-DOM web component. + * Two views: "pool" (canvas orbs) and "weave" (SVG node editor). + */ + +// ── Constants ── + +const SKILL_COLORS: Record = { + facilitation: '#8b5cf6', design: '#ec4899', tech: '#3b82f6', + outreach: '#10b981', logistics: '#f59e0b', +}; +const SKILL_LABELS: Record = { + facilitation: 'Facilitation', design: 'Design', tech: 'Tech', + outreach: 'Outreach', logistics: 'Logistics', +}; + +const EXEC_STEPS: Record = { + default: [ + { title: 'Set Up Space', desc: 'Configure the physical or virtual venue', detail: 'venue', icon: '1' }, + { title: 'Communications Hub', desc: 'Create group chat and notification channels', detail: 'comms', icon: '2' }, + { title: 'Shared Notes & Docs', desc: 'Set up collaborative agenda and resource links', detail: 'notes', icon: '3' }, + { title: 'Prep from Input', desc: 'Upload transcripts or notes to auto-generate briefs', detail: 'prep', icon: '4' }, + { title: 'Launch & Coordinate', desc: 'Confirm roles, send notifications, begin execution', detail: 'launch', icon: '5' }, + ], +}; + +const NODE_W = 90, NODE_H = 104; +const TASK_W = 220, TASK_H_BASE = 52, TASK_ROW = 26; +const EXEC_BTN_H = 28; +const PORT_R = 5.5; +const HEX_R = 52; + +interface Commitment { + id: string; + memberName: string; + hours: number; + skill: string; + desc: string; +} +interface TaskData { + id: string; + name: string; + description: string; + needs: Record; + links: { label: string; url: string }[]; + notes: string; + fulfilled?: Record; +} +interface WeaveNode { + id: string; + type: 'commitment' | 'task'; + x: number; + y: number; + w: number; + h: number; + hexR?: number; + baseH?: number; + data: any; +} +interface Wire { + from: string; + to: string; + skill: string; +} + +// ── Orb class ── + +class Orb { + c: Commitment; + baseRadius: number; + radius: number; + x: number; + y: number; + vx: number; + vy: number; + hoverT = 0; + phase: number; + opacity = 0; + color: string; + + constructor(c: Commitment, basketCX: number, basketCY: number, basketR: number, x?: number, y?: number) { + this.c = c; + this.baseRadius = 18 + c.hours * 9; + this.radius = this.baseRadius; + if (x != null && y != null) { + this.x = x; this.y = y; + } else { + const a = Math.random() * Math.PI * 2; + const r = Math.random() * (basketR - this.baseRadius - 10); + this.x = basketCX + Math.cos(a) * r; + this.y = basketCY + Math.sin(a) * r; + } + this.vx = (Math.random() - 0.5) * 0.4; + this.vy = (Math.random() - 0.5) * 0.4; + this.phase = Math.random() * Math.PI * 2; + this.color = SKILL_COLORS[c.skill] || '#8b5cf6'; + } + + update(hoveredOrb: Orb | null, basketCX: number, basketCY: number, basketR: number) { + this.phase += 0.008; + this.vx += Math.sin(this.phase) * 0.003; + this.vy += Math.cos(this.phase * 0.73 + 1) * 0.003; + this.vx *= 0.996; + this.vy *= 0.996; + this.x += this.vx; + this.y += this.vy; + + const dx = this.x - basketCX, dy = this.y - basketCY; + const dist = Math.sqrt(dx * dx + dy * dy); + const maxDist = basketR - this.radius - 3; + if (dist > maxDist && dist > 0.1) { + const nx = dx / dist, ny = dy / dist; + this.x = basketCX + nx * maxDist; + this.y = basketCY + ny * maxDist; + const dot = this.vx * nx + this.vy * ny; + this.vx -= 1.3 * dot * nx; + this.vy -= 1.3 * dot * ny; + this.vx *= 0.6; this.vy *= 0.6; + } + + const isH = hoveredOrb === this; + this.hoverT += ((isH ? 1 : 0) - this.hoverT) * 0.12; + this.radius = this.baseRadius + this.hoverT * 5; + if (this.opacity < 1) this.opacity = Math.min(1, this.opacity + 0.025); + } + + draw(ctx: CanvasRenderingContext2D) { + if (this.opacity < 0.01) return; + ctx.save(); + ctx.globalAlpha = this.opacity; + if (this.hoverT > 0.05) { ctx.shadowColor = this.color; ctx.shadowBlur = 24 * this.hoverT; } + + ctx.beginPath(); + ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); + ctx.fillStyle = this.color + '15'; + ctx.fill(); + + ctx.beginPath(); + ctx.arc(this.x, this.y, this.radius * 0.82, 0, Math.PI * 2); + const g = ctx.createRadialGradient( + this.x - this.radius * 0.15, this.y - this.radius * 0.15, 0, + this.x, this.y, this.radius * 0.82 + ); + g.addColorStop(0, this.color + 'dd'); + g.addColorStop(1, this.color); + ctx.fillStyle = g; + ctx.fill(); + ctx.shadowBlur = 0; + ctx.strokeStyle = this.color; + ctx.lineWidth = 1.5; + ctx.stroke(); + + if (this.hoverT > 0.05) { + ctx.globalAlpha = this.opacity * 0.08 * this.hoverT; + ctx.beginPath(); + ctx.ellipse(this.x, this.y + this.radius + 4, this.radius * 0.6, 4, 0, 0, Math.PI * 2); + ctx.fillStyle = '#000'; + ctx.fill(); + ctx.globalAlpha = this.opacity; + } + + ctx.fillStyle = '#fff'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const fs = Math.max(10, this.radius * 0.3); + ctx.font = '600 ' + fs + 'px -apple-system, sans-serif'; + ctx.fillText(this.c.memberName.split(' ')[0], this.x, this.y - fs * 0.35); + ctx.font = '500 ' + (fs * 0.78) + 'px -apple-system, sans-serif'; + ctx.fillStyle = '#ffffffcc'; + ctx.fillText(this.c.hours + 'hr \u00b7 ' + (SKILL_LABELS[this.c.skill] || this.c.skill), this.x, this.y + fs * 0.55); + + ctx.restore(); + } + + contains(px: number, py: number) { + const dx = px - this.x, dy = py - this.y; + return dx * dx + dy * dy < this.radius * this.radius; + } +} + +// ── Ripple class ── + +class Ripple { + x: number; y: number; r = 8; o = 0.5; color: string; + constructor(x: number, y: number, color: string) { this.x = x; this.y = y; this.color = color; } + update() { this.r += 1.5; this.o -= 0.008; return this.o > 0; } + draw(ctx: CanvasRenderingContext2D) { + ctx.save(); ctx.globalAlpha = this.o; + ctx.strokeStyle = this.color; ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2); ctx.stroke(); + ctx.restore(); + } +} + +// ── Hex helpers ── + +function hexPoints(cx: number, cy: number, r: number): [number, number][] { + return [0, 1, 2, 3, 4, 5].map(i => { + const angle = -Math.PI / 2 + i * Math.PI / 3; + return [cx + r * Math.cos(angle), cy + r * Math.sin(angle)] as [number, number]; + }); +} + +function hexPolygonStr(cx: number, cy: number, r: number) { + return hexPoints(cx, cy, r).map(p => p[0] + ',' + p[1]).join(' '); +} + +function bezier(x1: number, y1: number, x2: number, y2: number) { + const d = Math.max(40, Math.abs(x2 - x1) * 0.5); + return 'M' + x1 + ',' + y1 + ' C' + (x1 + d) + ',' + y1 + ' ' + (x2 - d) + ',' + y2 + ' ' + x2 + ',' + y2; +} + +function ns(tag: string) { return document.createElementNS('http://www.w3.org/2000/svg', tag); } + +function svgText(txt: string, x: number, y: number, size: number, color: string, weight?: string, anchor?: string) { + const t = ns('text'); + t.setAttribute('x', String(x)); t.setAttribute('y', String(y)); + t.setAttribute('fill', color); + t.setAttribute('font-size', String(size)); + t.setAttribute('font-weight', weight || '400'); + t.setAttribute('font-family', '-apple-system, BlinkMacSystemFont, sans-serif'); + if (anchor) t.setAttribute('text-anchor', anchor); + t.textContent = txt; + return t; +} + +// ── Main component ── + +class FolkTimebankApp extends HTMLElement { + private shadow: ShadowRoot; + private space = 'demo'; + private currentView: 'pool' | 'weave' = 'pool'; + + // Pool state + private canvas!: HTMLCanvasElement; + private ctx!: CanvasRenderingContext2D; + private orbs: Orb[] = []; + private ripples: Ripple[] = []; + private animFrame = 0; + private poolW = 0; + private poolH = 0; + private basketCX = 0; + private basketCY = 0; + private basketR = 0; + private hoveredOrb: Orb | null = null; + private selectedOrb: Orb | null = null; + private dpr = 1; + private poolPointerId: number | null = null; + private poolPointerStart: { x: number; y: number; cx: number; cy: number } | null = null; + + // Weave state + private svgEl!: SVGSVGElement; + private nodesLayer!: SVGGElement; + private connectionsLayer!: SVGGElement; + private tempConn!: SVGPathElement; + private weaveNodes: WeaveNode[] = []; + private connections: Wire[] = []; + private dragNode: WeaveNode | null = null; + private dragOff = { x: 0, y: 0 }; + private connecting: { nodeId: string; portType: string; skill: string | null } | null = null; + private svgActivePointerId: number | null = null; + private svgDblTapTime = 0; + private svgDblTapPos: { x: number; y: number } | null = null; + private sidebarDragData: { type: string; id: string } | null = null; + private sidebarGhost: HTMLElement | null = null; + + // Data + private commitments: Commitment[] = []; + private tasks: TaskData[] = []; + + // Exec state + private execStepStates: Record> = {}; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: 'open' }); + } + + static get observedAttributes() { return ['space', 'view']; } + + attributeChangedCallback(name: string, _old: string, val: string) { + if (name === 'space') this.space = val; + if (name === 'view' && (val === 'pool' || val === 'weave')) this.currentView = val; + } + + connectedCallback() { + this.space = this.getAttribute('space') || 'demo'; + this.currentView = (this.getAttribute('view') as any) || 'pool'; + this.dpr = window.devicePixelRatio || 1; + this.render(); + this.setupPool(); + this.setupWeave(); + this.fetchData(); + } + + disconnectedCallback() { + if (this.animFrame) cancelAnimationFrame(this.animFrame); + } + + private async fetchData() { + const base = `/${this.space}/rtime`; + try { + const [cResp, tResp] = await Promise.all([ + fetch(`${base}/api/commitments`), + fetch(`${base}/api/tasks`), + ]); + if (cResp.ok) { + const cData = await cResp.json(); + this.commitments = cData.commitments || []; + } + if (tResp.ok) { + const tData = await tResp.json(); + this.tasks = tData.tasks || []; + } + } catch { + // Offline — use empty state + } + this.buildOrbs(); + this.updateStats(); + this.rebuildSidebar(); + } + + private render() { + this.shadow.innerHTML = ` + +
+
Commitment Pool
+
Weaving Dashboard
+
+
+
0 hours available
+
0 contributors
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+ +
+ + + + + +
+
+
+

Project Execution

+

All commitments woven — prepare for launch

+
+
+ +
+
+ + +
+
+

Edit Task

+
+
+
+
+
+ +
+
+`; + } + + // ── Pool setup ── + + private setupPool() { + this.canvas = this.shadow.getElementById('pool-canvas') as HTMLCanvasElement; + this.ctx = this.canvas.getContext('2d')!; + + // Tab switching + this.shadow.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', () => { + const view = (tab as HTMLElement).dataset.view as 'pool' | 'weave'; + if (view === this.currentView) return; + this.currentView = view; + this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === view)); + const poolView = this.shadow.getElementById('pool-view')!; + const weaveView = this.shadow.getElementById('weave-view')!; + poolView.style.display = view === 'pool' ? 'block' : 'none'; + weaveView.style.display = view === 'weave' ? 'flex' : 'none'; + if (view === 'pool') this.resizePoolCanvas(); + if (view === 'weave') this.rebuildSidebar(); + }); + }); + + // Add commitment modal + const modal = this.shadow.getElementById('modalOverlay')!; + this.shadow.getElementById('addBtn')!.addEventListener('click', () => { + modal.classList.add('visible'); + (this.shadow.getElementById('modalName') as HTMLInputElement).value = ''; + (this.shadow.getElementById('modalHours') as HTMLInputElement).value = '2'; + (this.shadow.getElementById('modalDesc') as HTMLInputElement).value = ''; + (this.shadow.getElementById('modalName') as HTMLInputElement).focus(); + }); + this.shadow.getElementById('modalCancel')!.addEventListener('click', () => modal.classList.remove('visible')); + modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('visible'); }); + this.shadow.getElementById('modalSubmit')!.addEventListener('click', () => this.submitCommitment()); + + // Pool pointer events + this.canvas.addEventListener('pointerdown', (e) => this.onPoolPointerDown(e)); + this.canvas.addEventListener('pointermove', (e) => this.onPoolPointerMove(e)); + this.canvas.addEventListener('pointerup', (e) => this.onPoolPointerUp(e)); + this.canvas.addEventListener('pointercancel', () => { this.poolPointerId = null; this.poolPointerStart = null; }); + this.canvas.addEventListener('pointerleave', (e) => { if (e.pointerType === 'mouse') { this.hoveredOrb = null; this.hideDetail(); } }); + + // Exec panel + this.shadow.getElementById('execClose')!.addEventListener('click', () => this.shadow.getElementById('execOverlay')!.classList.remove('visible')); + this.shadow.getElementById('execOverlay')!.addEventListener('click', (e) => { + if (e.target === this.shadow.getElementById('execOverlay')) this.shadow.getElementById('execOverlay')!.classList.remove('visible'); + }); + this.shadow.getElementById('execLaunch')!.addEventListener('click', () => { + const btn = this.shadow.getElementById('execLaunch') as HTMLButtonElement; + btn.textContent = 'Launched!'; + btn.style.background = 'linear-gradient(135deg, #8b5cf6, #ec4899)'; + setTimeout(() => { + this.shadow.getElementById('execOverlay')!.classList.remove('visible'); + btn.textContent = 'Launch Project'; + btn.style.background = ''; + }, 1500); + }); + + // Task editor + this.shadow.getElementById('taskEditCancel')!.addEventListener('click', () => this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible')); + this.shadow.getElementById('taskEditOverlay')!.addEventListener('click', (e) => { + if (e.target === this.shadow.getElementById('taskEditOverlay')) this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible'); + }); + this.shadow.getElementById('taskEditSave')!.addEventListener('click', () => this.saveTaskEdit()); + + // Resize + const resizeObserver = new ResizeObserver(() => { if (this.currentView === 'pool') this.resizePoolCanvas(); }); + resizeObserver.observe(this); + + this.resizePoolCanvas(); + this.poolFrame(); + } + + private resizePoolCanvas() { + const rect = this.canvas.parentElement!.getBoundingClientRect(); + this.poolW = rect.width; + this.poolH = rect.height; + this.canvas.width = this.poolW * this.dpr; + this.canvas.height = this.poolH * this.dpr; + this.canvas.style.width = this.poolW + 'px'; + this.canvas.style.height = this.poolH + 'px'; + this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.basketCX = this.poolW / 2; + this.basketCY = this.poolH / 2 + 10; + this.basketR = Math.min(this.poolW, this.poolH) * 0.42; + } + + private buildOrbs() { + this.orbs = this.commitments.map(c => new Orb(c, this.basketCX, this.basketCY, this.basketR)); + } + + private resolveCollisions() { + for (let i = 0; i < this.orbs.length; i++) { + for (let j = i + 1; j < this.orbs.length; j++) { + const a = this.orbs[i], b = this.orbs[j]; + const dx = b.x - a.x, dy = b.y - a.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const min = a.radius + b.radius + 4; + if (dist < min && dist > 0.1) { + const nx = dx / dist, ny = dy / dist; + const ov = (min - dist) * 0.5; + a.x -= nx * ov * 0.5; a.y -= ny * ov * 0.5; + b.x += nx * ov * 0.5; b.y += ny * ov * 0.5; + a.vx -= nx * 0.04; a.vy -= ny * 0.04; + b.vx += nx * 0.04; b.vy += ny * 0.04; + } + } + } + } + + private drawBasket() { + const ctx = this.ctx; + const ig = ctx.createRadialGradient(this.basketCX, this.basketCY - this.basketR * 0.2, 0, this.basketCX, this.basketCY, this.basketR); + ig.addColorStop(0, 'rgba(139,92,246,0.06)'); + ig.addColorStop(0.7, 'rgba(139,92,246,0.1)'); + ig.addColorStop(1, 'rgba(236,72,153,0.12)'); + ctx.beginPath(); + ctx.arc(this.basketCX, this.basketCY, this.basketR, 0, Math.PI * 2); + ctx.fillStyle = ig; + ctx.fill(); + + ctx.beginPath(); + ctx.arc(this.basketCX, this.basketCY, this.basketR, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(139,92,246,0.25)'; + ctx.lineWidth = 6; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(this.basketCX, this.basketCY, this.basketR, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(139,92,246,0.4)'; + ctx.lineWidth = 2; + ctx.stroke(); + + // Woven texture + ctx.strokeStyle = 'rgba(139,92,246,0.2)'; + ctx.lineWidth = 1.5; + const segs = 48; + for (let i = 0; i < segs; i++) { + const a1 = (i / segs) * Math.PI * 2; + const a2 = ((i + 0.5) / segs) * Math.PI * 2; + const r1 = this.basketR - 3, r2 = this.basketR + 3; + ctx.beginPath(); + ctx.moveTo(this.basketCX + Math.cos(a1) * r1, this.basketCY + Math.sin(a1) * r1); + ctx.quadraticCurveTo( + this.basketCX + Math.cos((a1 + a2) / 2) * r2, + this.basketCY + Math.sin((a1 + a2) / 2) * r2, + this.basketCX + Math.cos(a2) * r1, + this.basketCY + Math.sin(a2) * r1 + ); + ctx.stroke(); + } + + ctx.fillStyle = '#a78bfacc'; + ctx.font = '600 13px -apple-system, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillText('COMMITMENT BASKET', this.basketCX, this.basketCY - this.basketR - 14); + } + + private poolFrame = () => { + this.ctx.clearRect(0, 0, this.poolW, this.poolH); + const bg = this.ctx.createLinearGradient(0, 0, 0, this.poolH); + bg.addColorStop(0, '#0f172a'); bg.addColorStop(1, '#1a1033'); + this.ctx.fillStyle = bg; this.ctx.fillRect(0, 0, this.poolW, this.poolH); + + this.drawBasket(); + this.ripples = this.ripples.filter(r => r.update()); + this.ripples.forEach(r => r.draw(this.ctx)); + this.orbs.forEach(o => o.update(this.hoveredOrb, this.basketCX, this.basketCY, this.basketR)); + this.resolveCollisions(); + this.orbs.forEach(o => o.draw(this.ctx)); + this.animFrame = requestAnimationFrame(this.poolFrame); + }; + + // Pool pointer handlers + + private onPoolPointerDown(e: PointerEvent) { + if (this.poolPointerId !== null) return; + this.poolPointerId = e.pointerId; + this.canvas.setPointerCapture(e.pointerId); + const r = this.canvas.getBoundingClientRect(); + this.poolPointerStart = { x: e.clientX, y: e.clientY, cx: e.clientX - r.left, cy: e.clientY - r.top }; + } + + private onPoolPointerMove(e: PointerEvent) { + const r = this.canvas.getBoundingClientRect(); + const mx = e.clientX - r.left, my = e.clientY - r.top; + this.hoveredOrb = null; + for (let i = this.orbs.length - 1; i >= 0; i--) { + if (this.orbs[i].contains(mx, my)) { this.hoveredOrb = this.orbs[i]; break; } + } + this.canvas.style.cursor = this.hoveredOrb ? 'pointer' : 'default'; + if (this.poolPointerStart) { + const dx = e.clientX - this.poolPointerStart.x, dy = e.clientY - this.poolPointerStart.y; + if (dx * dx + dy * dy > 100) this.poolPointerStart = null; + } + } + + private onPoolPointerUp(e: PointerEvent) { + if (e.pointerId !== this.poolPointerId) return; + this.poolPointerId = null; + if (!this.poolPointerStart) return; + const r = this.canvas.getBoundingClientRect(); + const px = e.clientX - r.left, py = e.clientY - r.top; + let clicked: Orb | null = null; + for (let i = this.orbs.length - 1; i >= 0; i--) { + if (this.orbs[i].contains(px, py)) { clicked = this.orbs[i]; break; } + } + if (clicked) { + this.selectedOrb = this.selectedOrb === clicked ? null : clicked; + if (this.selectedOrb) this.showDetail(this.selectedOrb, e.clientX, e.clientY); else this.hideDetail(); + } else { this.selectedOrb = null; this.hideDetail(); } + this.poolPointerStart = null; + } + + private showDetail(orb: Orb, cx: number, cy: number) { + const el = this.shadow.getElementById('poolDetail')!; + const c = orb.c; + (this.shadow.getElementById('detailDot') as HTMLElement).style.background = SKILL_COLORS[c.skill] || '#8b5cf6'; + this.shadow.getElementById('detailName')!.textContent = c.memberName; + this.shadow.getElementById('detailSkill')!.textContent = SKILL_LABELS[c.skill] || c.skill; + this.shadow.getElementById('detailHours')!.textContent = c.hours + ' hour' + (c.hours !== 1 ? 's' : '') + ' pledged'; + this.shadow.getElementById('detailDesc')!.textContent = c.desc; + const mr = this.shadow.querySelector('.main')!.getBoundingClientRect(); + let left = cx - mr.left + 15, top = cy - mr.top - 30; + if (left + 240 > mr.width) left = cx - mr.left - 250; + if (top + 150 > mr.height) top = mr.height - 160; + if (top < 10) top = 10; + el.style.left = left + 'px'; el.style.top = top + 'px'; + el.classList.add('visible'); + } + + private hideDetail() { this.shadow.getElementById('poolDetail')?.classList.remove('visible'); } + + // ── Submit commitment ── + + private async submitCommitment() { + const name = (this.shadow.getElementById('modalName') as HTMLInputElement).value.trim(); + const skill = (this.shadow.getElementById('modalSkill') as HTMLSelectElement).value; + const hours = Math.max(1, Math.min(10, parseInt((this.shadow.getElementById('modalHours') as HTMLInputElement).value) || 2)); + const desc = (this.shadow.getElementById('modalDesc') as HTMLInputElement).value.trim() || (SKILL_LABELS[skill] || skill) + ' contribution'; + if (!name) { (this.shadow.getElementById('modalName') as HTMLInputElement).focus(); return; } + + const c: Commitment = { id: 'local-' + Date.now(), memberName: name, skill, hours, desc }; + + // Try server-side persist + try { + const resp = await fetch(`/${this.space}/rtime/api/commitments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ memberName: name, skill, hours, desc }), + }); + if (resp.ok) { + const saved = await resp.json(); + c.id = saved.id; + } + } catch { /* offline — keep local id */ } + + this.commitments.push(c); + const orb = new Orb(c, this.basketCX, this.basketCY, this.basketR, + this.basketCX + (Math.random() - 0.5) * 40, this.basketCY - this.basketR - 30); + orb.vy = 2.5; + this.orbs.push(orb); + setTimeout(() => { + this.ripples.push(new Ripple(this.basketCX, this.basketCY, SKILL_COLORS[skill] || '#8b5cf6')); + this.ripples.push(new Ripple(this.basketCX - 15, this.basketCY + 10, SKILL_COLORS[skill] || '#8b5cf6')); + }, 400); + this.updateStats(); + this.shadow.getElementById('modalOverlay')!.classList.remove('visible'); + } + + // ── Stats bar ── + + private updateStats() { + const total = this.commitments.reduce((s, c) => s + c.hours, 0); + this.shadow.getElementById('statHours')!.textContent = String(total); + this.shadow.getElementById('statContributors')!.textContent = String(this.commitments.length); + const bySkill: Record = {}; + this.commitments.forEach(c => { bySkill[c.skill] = (bySkill[c.skill] || 0) + c.hours; }); + const bar = this.shadow.getElementById('skillBar')!; + const legend = this.shadow.getElementById('skillLegend')!; + bar.innerHTML = ''; legend.innerHTML = ''; + if (total === 0) return; + for (const [skill, hrs] of Object.entries(bySkill)) { + const seg = document.createElement('div'); + seg.className = 'skill-bar-segment'; + seg.style.width = ((hrs as number) / total * 100) + '%'; + seg.style.background = SKILL_COLORS[skill] || '#888'; + bar.appendChild(seg); + const item = document.createElement('div'); + item.className = 'skill-legend-item'; + item.innerHTML = '
' + (SKILL_LABELS[skill] || skill) + ' ' + hrs + 'h'; + legend.appendChild(item); + } + } + + // ── Weave setup ── + + private setupWeave() { + this.svgEl = this.shadow.getElementById('weave-svg') as any; + this.nodesLayer = this.shadow.getElementById('nodesLayer') as any; + this.connectionsLayer = this.shadow.getElementById('connectionsLayer') as any; + this.tempConn = this.shadow.getElementById('tempConnection') as any; + + this.svgEl.addEventListener('pointerdown', (e: PointerEvent) => this.onSvgPointerDown(e)); + this.svgEl.addEventListener('pointermove', (e: PointerEvent) => this.onSvgPointerMove(e)); + this.svgEl.addEventListener('pointerup', (e: PointerEvent) => this.onSvgPointerUp(e)); + this.svgEl.addEventListener('pointercancel', () => { + this.svgActivePointerId = null; this.connecting = null; this.dragNode = null; + this.tempConn.style.display = 'none'; + }); + this.svgEl.addEventListener('dblclick', (e) => { + const ng = (e.target as Element).closest('.node-group') as SVGElement; + if (!ng) return; + const taskNode = this.weaveNodes.find(n => n.id === ng.dataset.id && n.type === 'task'); + if (taskNode) this.openTaskEditor(taskNode); + }); + + // Drop zone + const wrap = this.shadow.getElementById('canvasWrap')!; + wrap.addEventListener('dragover', (e) => { e.preventDefault(); }); + } + + private svgPt(cx: number, cy: number) { + const p = this.svgEl.createSVGPoint(); + p.x = cx; p.y = cy; + return p.matrixTransform(this.svgEl.getScreenCTM()!.inverse()); + } + + private mkCommitNode(c: Commitment, x: number, y: number): WeaveNode { + return { id: 'cn-' + c.id, type: 'commitment', x, y, w: NODE_W, h: NODE_H, hexR: HEX_R, data: c }; + } + + private mkTaskNode(t: TaskData, x: number, y: number): WeaveNode { + const n = Object.keys(t.needs).length; + const baseH = TASK_H_BASE + n * TASK_ROW + 12; + return { id: t.id, type: 'task', x, y, w: TASK_W, h: baseH, baseH, data: { ...t, fulfilled: {} } }; + } + + private isTaskReady(node: WeaveNode): boolean { + if (node.type !== 'task') return false; + const t = node.data; + for (const skill of Object.keys(t.needs)) { + if ((t.fulfilled?.[skill] || 0) < t.needs[skill]) return false; + } + return true; + } + + private renderConnections() { + this.connectionsLayer.innerHTML = ''; + this.connections.forEach(conn => { + const from = this.weaveNodes.find(n => n.id === conn.from); + const to = this.weaveNodes.find(n => n.id === conn.to); + if (!from || !to) return; + let x1: number, y1: number; + if (from.type === 'commitment') { + const cx = from.x + from.w / 2, cy = from.y + from.h / 2; + const pts = hexPoints(cx, cy, from.hexR || HEX_R); + x1 = pts[1][0]; y1 = pts[1][1]; + } else { + x1 = from.x + from.w; y1 = from.y + from.h / 2; + } + let x2 = to.x, y2 = to.y + to.h / 2; + if (to.type === 'task') { + const skills = Object.keys(to.data.needs); + const idx = skills.indexOf(conn.skill); + if (idx >= 0) y2 = to.y + TASK_H_BASE + idx * TASK_ROW + TASK_ROW / 2; + } + const p = ns('path'); + p.setAttribute('d', bezier(x1, y1, x2, y2)); + p.setAttribute('class', 'connection-line active'); + this.connectionsLayer.appendChild(p); + }); + } + + private renderNode(node: WeaveNode): SVGGElement { + const g = ns('g') as SVGGElement; + g.setAttribute('data-id', node.id); + g.setAttribute('transform', 'translate(' + node.x + ',' + node.y + ')'); + + if (node.type === 'commitment') { + const c = node.data as Commitment; + const col = SKILL_COLORS[c.skill] || '#8b5cf6'; + const cx = node.w / 2, cy = node.h / 2; + const hr = node.hexR || HEX_R; + g.setAttribute('class', 'node-group'); + + const hex = ns('polygon'); + hex.setAttribute('points', hexPolygonStr(cx, cy, hr)); + hex.setAttribute('fill', '#1e293b'); + hex.setAttribute('stroke', '#334155'); + hex.setAttribute('stroke-width', '1'); + hex.setAttribute('filter', 'url(#nodeShadow)'); + g.appendChild(hex); + + const clipId = 'hexClip-' + node.id; + const clipPath = ns('clipPath'); + clipPath.setAttribute('id', clipId); + const clipRect = ns('rect'); + clipRect.setAttribute('x', String(cx - hr)); clipRect.setAttribute('y', String(cy - hr)); + clipRect.setAttribute('width', String(hr * 2)); clipRect.setAttribute('height', String(hr * 0.75)); + clipPath.appendChild(clipRect); + g.appendChild(clipPath); + + const headerFill = ns('polygon'); + headerFill.setAttribute('points', hexPolygonStr(cx, cy, hr)); + headerFill.setAttribute('fill', col); + headerFill.setAttribute('clip-path', 'url(#' + clipId + ')'); + g.appendChild(headerFill); + + const nameText = c.memberName.length > 12 ? c.memberName.slice(0, 11) + '\u2026' : c.memberName; + g.appendChild(svgText(nameText, cx, cy - hr * 0.35, 11, '#fff', '600', 'middle')); + g.appendChild(svgText(c.hours + 'hr', cx, cy + 4, 13, '#f1f5f9', '700', 'middle')); + g.appendChild(svgText(SKILL_LABELS[c.skill] || c.skill, cx, cy + 18, 10, '#94a3b8', '400', 'middle')); + + const pts = hexPoints(cx, cy, hr); + const portHit = ns('circle'); + portHit.setAttribute('cx', String(pts[1][0])); portHit.setAttribute('cy', String(pts[1][1])); + portHit.setAttribute('r', '18'); portHit.setAttribute('fill', 'transparent'); + portHit.setAttribute('class', 'port'); + (portHit as any).dataset = {}; portHit.setAttribute('data-node', node.id); portHit.setAttribute('data-port', 'output'); + g.appendChild(portHit); + const port = ns('circle'); + port.setAttribute('cx', String(pts[1][0])); port.setAttribute('cy', String(pts[1][1])); + port.setAttribute('r', String(PORT_R)); + port.setAttribute('fill', col); port.setAttribute('stroke', '#fff'); port.setAttribute('stroke-width', '2'); + port.style.pointerEvents = 'none'; + g.appendChild(port); + + } else if (node.type === 'task') { + 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; + const ready = this.isTaskReady(node); + + node.h = node.baseH! + (ready ? EXEC_BTN_H + 8 : 0); + g.setAttribute('class', 'node-group task-node' + (ready ? ' ready' : '')); + + const rect = ns('rect'); + rect.setAttribute('class', 'node-rect'); + rect.setAttribute('width', String(node.w)); rect.setAttribute('height', String(node.h)); + rect.setAttribute('rx', '8'); rect.setAttribute('ry', '8'); + rect.setAttribute('filter', ready ? 'url(#glowGreen)' : 'url(#nodeShadow)'); + g.appendChild(rect); + + const hCol = ready ? '#10b981' : '#8b5cf6'; + const h = ns('rect'); + h.setAttribute('width', String(node.w)); h.setAttribute('height', '28'); + h.setAttribute('fill', hCol); h.setAttribute('rx', '8'); h.setAttribute('ry', '8'); + g.appendChild(h); + const hm = ns('rect'); + hm.setAttribute('width', String(node.w)); hm.setAttribute('height', '10'); hm.setAttribute('y', '20'); hm.setAttribute('fill', hCol); + g.appendChild(hm); + + g.appendChild(svgText(t.name, 12, 18, 12, '#fff', '600')); + g.appendChild(svgText(ready ? 'Ready!' : Math.round(progress * 100) + '%', 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); + + skills.forEach((skill, i) => { + const ry = TASK_H_BASE + i * TASK_ROW; + const needed = t.needs[skill]; + const ful = (t.fulfilled || {})[skill] || 0; + const done = ful >= needed; + + const portHit = ns('circle'); + portHit.setAttribute('cx', '0'); portHit.setAttribute('cy', String(ry + TASK_ROW / 2)); + portHit.setAttribute('r', '18'); portHit.setAttribute('fill', 'transparent'); + portHit.setAttribute('class', 'port'); + portHit.setAttribute('data-node', node.id); portHit.setAttribute('data-port', 'input'); portHit.setAttribute('data-skill', skill); + g.appendChild(portHit); + const port = ns('circle'); + port.setAttribute('cx', '0'); port.setAttribute('cy', String(ry + TASK_ROW / 2)); + port.setAttribute('r', String(PORT_R)); + port.setAttribute('fill', done ? '#10b981' : (SKILL_COLORS[skill] || '#888')); + port.setAttribute('stroke', '#fff'); port.setAttribute('stroke-width', '2'); + port.style.pointerEvents = 'none'; + g.appendChild(port); + + const dot = ns('circle'); + dot.setAttribute('cx', '20'); dot.setAttribute('cy', String(ry + TASK_ROW / 2)); + dot.setAttribute('r', '4'); dot.setAttribute('fill', SKILL_COLORS[skill] || '#888'); + g.appendChild(dot); + + const lbl = (SKILL_LABELS[skill] || skill) + ': ' + ful + '/' + needed + 'hr'; + g.appendChild(svgText(lbl, 30, ry + TASK_ROW / 2 + 4, 11, done ? '#10b981' : '#94a3b8', done ? '600' : '400')); + if (done) g.appendChild(svgText('\u2713', node.w - 16, ry + TASK_ROW / 2 + 4, 13, '#10b981', '600', 'middle')); + }); + + if (ready) { + const btnY = node.baseH! + 4; + const btnR = ns('rect'); + btnR.setAttribute('x', '12'); btnR.setAttribute('y', String(btnY)); + btnR.setAttribute('width', String(node.w - 24)); btnR.setAttribute('height', String(EXEC_BTN_H)); + btnR.setAttribute('rx', '6'); btnR.setAttribute('ry', '6'); + btnR.setAttribute('fill', '#10b981'); + btnR.setAttribute('class', 'exec-btn-rect'); + btnR.setAttribute('data-task-id', node.id); + g.appendChild(btnR); + const btnT = svgText('Execute Project \u2192', node.w / 2, btnY + EXEC_BTN_H / 2 + 4, 12, '#fff', '600', 'middle'); + btnT.style.pointerEvents = 'none'; + g.appendChild(btnT); + } + } + + return g; + } + + private renderAll() { + this.nodesLayer.innerHTML = ''; + this.weaveNodes.forEach(n => this.nodesLayer.appendChild(this.renderNode(n))); + this.renderConnections(); + } + + // SVG pointer events + + private onSvgPointerDown(e: PointerEvent) { + if (this.svgActivePointerId !== null) return; + this.svgActivePointerId = e.pointerId; + this.svgEl.setPointerCapture(e.pointerId); + + if ((e.target as Element).classList.contains('exec-btn-rect')) { + const taskId = (e.target as HTMLElement).getAttribute('data-task-id'); + if (taskId) this.openExecPanel(taskId); + return; + } + + const port = (e.target as Element).closest('.port') as SVGElement; + if (port) { + e.preventDefault(); + this.connecting = { nodeId: port.getAttribute('data-node')!, portType: port.getAttribute('data-port')!, skill: port.getAttribute('data-skill') }; + this.tempConn.style.display = 'block'; + return; + } + const ng = (e.target as Element).closest('.node-group') as SVGElement; + if (ng) { + const id = ng.dataset.id!; + this.dragNode = this.weaveNodes.find(n => n.id === id) || null; + if (this.dragNode) { + const pt = this.svgPt(e.clientX, e.clientY); + this.dragOff.x = pt.x - this.dragNode.x; + this.dragOff.y = pt.y - this.dragNode.y; + } + // Double-tap detection + const now = Date.now(); + if (this.svgDblTapPos && now - this.svgDblTapTime < 300) { + const dx = e.clientX - this.svgDblTapPos.x, dy = e.clientY - this.svgDblTapPos.y; + if (dx * dx + dy * dy < 225) { + const taskNode = this.weaveNodes.find(n => n.id === id && n.type === 'task'); + if (taskNode) { this.openTaskEditor(taskNode); this.dragNode = null; } + } + } + this.svgDblTapTime = now; + this.svgDblTapPos = { x: e.clientX, y: e.clientY }; + } + } + + private onSvgPointerMove(e: PointerEvent) { + if (e.pointerId !== this.svgActivePointerId) return; + const pt = this.svgPt(e.clientX, e.clientY); + if (this.connecting) { + const fn = this.weaveNodes.find(n => n.id === this.connecting!.nodeId); + if (!fn) return; + if (this.connecting.portType === 'input') { + const skills = fn.type === 'task' ? Object.keys(fn.data.needs) : []; + const idx = skills.indexOf(this.connecting.skill!); + const x1 = fn.x; + const y1 = idx >= 0 ? fn.y + TASK_H_BASE + idx * TASK_ROW + TASK_ROW / 2 : fn.y + fn.h / 2; + this.tempConn.setAttribute('d', bezier(pt.x, pt.y, x1, y1)); + } else { + const outX = fn.x + fn.w, outY = fn.y + fn.h / 2; + this.tempConn.setAttribute('d', bezier(outX, outY, pt.x, pt.y)); + } + return; + } + if (this.dragNode) { + this.dragNode.x = pt.x - this.dragOff.x; + this.dragNode.y = pt.y - this.dragOff.y; + this.renderAll(); + } + } + + private onSvgPointerUp(e: PointerEvent) { + if (e.pointerId !== this.svgActivePointerId) return; + this.svgActivePointerId = null; + + if (this.connecting) { + const port = (e.target as Element).closest('.port') as SVGElement; + if (port) { + const tId = port.getAttribute('data-node')!; + const tType = port.getAttribute('data-port')!; + const tSkill = port.getAttribute('data-skill'); + let fromId: string | undefined, toId: string | undefined, skill: string | undefined; + if (this.connecting.portType === 'output' && tType === 'input' && tId !== this.connecting.nodeId) { + fromId = this.connecting.nodeId; toId = tId; skill = tSkill || undefined; + } else if (this.connecting.portType === 'input' && tType === 'output' && tId !== this.connecting.nodeId) { + fromId = tId; toId = this.connecting.nodeId; skill = this.connecting.skill || undefined; + } + if (fromId && toId && skill) { + const fNode = this.weaveNodes.find(n => n.id === fromId); + const tNode = this.weaveNodes.find(n => n.id === toId); + if (fNode && tNode && fNode.type === 'commitment' && tNode.type === 'task' && fNode.data.skill === skill) { + if (!this.connections.find(c => c.from === fromId && c.to === toId)) { + this.connections.push({ from: fromId, to: toId, skill }); + if (!tNode.data.fulfilled) tNode.data.fulfilled = {}; + tNode.data.fulfilled[skill] = (tNode.data.fulfilled[skill] || 0) + fNode.data.hours; + const nowReady = this.isTaskReady(tNode); + this.renderAll(); + this.rebuildSidebar(); + if (nowReady) setTimeout(() => this.openExecPanel(tNode.id), 600); + } + } + } + } + this.connecting = null; + this.tempConn.style.display = 'none'; + this.tempConn.setAttribute('d', ''); + return; + } + this.dragNode = null; + } + + // ── Sidebar ── + + private rebuildSidebar() { + const cont = this.shadow.getElementById('sidebarItems'); + if (!cont) return; + cont.innerHTML = ''; + const used = new Set(this.weaveNodes.filter(n => n.type === 'commitment').map(n => n.data.id)); + this.commitments.forEach(c => { + const el = document.createElement('div'); + el.className = 'sidebar-item' + (used.has(c.id) ? ' used' : ''); + el.innerHTML = '' + + ''; + if (!used.has(c.id)) { + el.addEventListener('pointerdown', (e) => this.startSidebarDrag(e, { type: 'commitment', id: c.id }, c.memberName)); + } + cont.appendChild(el); + }); + + const tc = this.shadow.getElementById('sidebarTasks'); + if (!tc) return; + tc.innerHTML = ''; + const usedT = new Set(this.weaveNodes.filter(n => n.type === 'task').map(n => n.id)); + this.tasks.forEach(t => { + if (usedT.has(t.id)) return; + const el = document.createElement('div'); + el.className = 'sidebar-task'; + const needs = Object.entries(t.needs).map(([s, h]) => h + 'hr ' + (SKILL_LABELS[s] || s)).join(', '); + el.innerHTML = '' + + ''; + el.addEventListener('pointerdown', (e) => this.startSidebarDrag(e, { type: 'task', id: t.id }, t.name)); + tc.appendChild(el); + }); + } + + private startSidebarDrag(e: PointerEvent, data: { type: string; id: string }, label: string) { + e.preventDefault(); + this.sidebarDragData = data; + this.sidebarGhost = document.createElement('div'); + this.sidebarGhost.style.cssText = 'position:fixed;padding:0.5rem 0.85rem;background:#1e293b;border:2px solid #8b5cf6;border-radius:0.5rem;font-size:0.8rem;font-weight:600;color:#e2e8f0;pointer-events:none;z-index:9999;box-shadow:0 4px 16px rgba(139,92,246,0.25);white-space:nowrap;'; + this.sidebarGhost.textContent = label; + this.sidebarGhost.style.left = e.clientX + 'px'; + this.sidebarGhost.style.top = (e.clientY - 20) + 'px'; + document.body.appendChild(this.sidebarGhost); + + const moveHandler = (ev: PointerEvent) => { + if (!this.sidebarGhost) return; + this.sidebarGhost.style.left = ev.clientX + 'px'; + this.sidebarGhost.style.top = (ev.clientY - 20) + 'px'; + }; + const upHandler = (ev: PointerEvent) => { + document.removeEventListener('pointermove', moveHandler); + document.removeEventListener('pointerup', upHandler); + document.removeEventListener('pointercancel', cancelHandler); + if (this.sidebarGhost) { this.sidebarGhost.remove(); this.sidebarGhost = null; } + if (!this.sidebarDragData) return; + + const wrap = this.shadow.getElementById('canvasWrap'); + if (!wrap) return; + const wr = wrap.getBoundingClientRect(); + if (ev.clientX >= wr.left && ev.clientX <= wr.right && ev.clientY >= wr.top && ev.clientY <= wr.bottom) { + const pt = this.svgPt(ev.clientX, ev.clientY); + if (this.sidebarDragData.type === 'commitment') { + const c = this.commitments.find(x => x.id === this.sidebarDragData!.id); + if (c && !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 if (this.sidebarDragData.type === 'task') { + const t = this.tasks.find(x => x.id === this.sidebarDragData!.id); + if (t && !this.weaveNodes.find(n => n.id === t.id)) { + this.weaveNodes.push(this.mkTaskNode(t, pt.x - TASK_W / 2, pt.y - 40)); + } + } + this.renderAll(); + this.rebuildSidebar(); + } + this.sidebarDragData = null; + }; + const cancelHandler = () => { + document.removeEventListener('pointermove', moveHandler); + document.removeEventListener('pointerup', upHandler); + document.removeEventListener('pointercancel', cancelHandler); + if (this.sidebarGhost) { this.sidebarGhost.remove(); this.sidebarGhost = null; } + this.sidebarDragData = null; + }; + + document.addEventListener('pointermove', moveHandler); + document.addEventListener('pointerup', upHandler); + document.addEventListener('pointercancel', cancelHandler); + } + + // ── Exec panel ── + + private openExecPanel(taskId: string) { + const node = this.weaveNodes.find(n => n.id === taskId); + if (!node || node.type !== 'task') return; + + const t = node.data as TaskData; + this.shadow.getElementById('execTitle')!.innerHTML = t.name + ' All Commitments Woven'; + this.shadow.getElementById('execSubtitle')!.textContent = 'Set up the project for execution'; + + const members = this.connections.filter(c => c.to === taskId).map(c => { + const cn = this.weaveNodes.find(n => n.id === c.from); + return cn ? cn.data.memberName : ''; + }).filter(Boolean); + + const steps = EXEC_STEPS[taskId] || EXEC_STEPS.default; + if (!this.execStepStates[taskId]) { + this.execStepStates[taskId] = {}; + steps.forEach((_, i) => { this.execStepStates[taskId][i] = 'pending'; }); + this.execStepStates[taskId][0] = 'active'; + } + + this.renderExecSteps(taskId, steps, members); + this.shadow.getElementById('execOverlay')!.classList.add('visible'); + } + + private renderExecSteps(taskId: string, steps: typeof EXEC_STEPS['default'], members: string[]) { + const container = this.shadow.getElementById('execSteps')!; + container.innerHTML = ''; + const states = this.execStepStates[taskId]; + let doneCount = 0; + + steps.forEach((step, i) => { + const state = states[i] || 'pending'; + if (state === 'done') doneCount++; + + const div = document.createElement('div'); + div.className = 'exec-step ' + state; + div.innerHTML = ` +
${state === 'done' ? '\u2713' : step.icon}
+
+
${step.title}
+
${step.desc}
+
+ `; + + div.addEventListener('click', () => { + if (states[i] === 'done') return; + if (states[i] === 'active') { + states[i] = 'done'; + } else { + Object.keys(states).forEach(k => { if (states[Number(k)] === 'active') states[Number(k)] = 'pending'; }); + states[i] = 'active'; + } + // Auto-advance + let nextActive = -1; + for (let j = 0; j < steps.length; j++) { + if (states[j] !== 'done') { nextActive = j; break; } + } + if (nextActive >= 0 && states[nextActive] !== 'active') { + Object.keys(states).forEach(k => { if (states[Number(k)] === 'active') states[Number(k)] = 'pending'; }); + states[nextActive] = 'active'; + } + this.renderExecSteps(taskId, steps, members); + }); + + container.appendChild(div); + }); + + const pct = steps.length > 0 ? (doneCount / steps.length * 100) : 0; + (this.shadow.getElementById('execProgressFill') as HTMLElement).style.width = pct + '%'; + this.shadow.getElementById('execProgressText')!.textContent = doneCount + '/' + steps.length; + (this.shadow.getElementById('execLaunch') as HTMLButtonElement).disabled = doneCount < steps.length; + } + + // ── Task editor ── + + private editingTaskNode: WeaveNode | null = null; + + private openTaskEditor(node: WeaveNode) { + this.editingTaskNode = node; + const t = node.data as TaskData; + this.shadow.getElementById('taskEditTitle')!.textContent = 'Edit: ' + t.name; + (this.shadow.getElementById('taskEditName') as HTMLInputElement).value = t.name; + (this.shadow.getElementById('taskEditDesc') as HTMLTextAreaElement).value = t.description || ''; + (this.shadow.getElementById('taskEditNotes') as HTMLTextAreaElement).value = t.notes || ''; + this.shadow.getElementById('taskEditOverlay')!.classList.add('visible'); + } + + private saveTaskEdit() { + if (!this.editingTaskNode) return; + const t = this.editingTaskNode.data as TaskData; + t.name = (this.shadow.getElementById('taskEditName') as HTMLInputElement).value.trim() || t.name; + t.description = (this.shadow.getElementById('taskEditDesc') as HTMLTextAreaElement).value.trim(); + t.notes = (this.shadow.getElementById('taskEditNotes') as HTMLTextAreaElement).value.trim(); + this.renderAll(); + this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible'); + this.editingTaskNode = null; + } +} + +// ── CSS ── + +const CSS_TEXT = ` +:host { + display: flex; + flex-direction: column; + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + color: #e2e8f0; + background: #0f172a; + line-height: 1.6; + overflow: hidden; +} + +.tab-bar { + display: flex; + background: #1e293b; + border-bottom: 1px solid #334155; + flex-shrink: 0; +} +.tab { + padding: 0.65rem 1.5rem; + font-size: 0.9rem; + font-weight: 500; + color: #64748b; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s; + user-select: none; +} +.tab:hover { color: #e2e8f0; } +.tab.active { color: #8b5cf6; border-bottom-color: #8b5cf6; } + +.stats-bar { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 0.6rem 1.5rem; + background: linear-gradient(135deg, #1e1b4b 0%, #2d1b3d 100%); + border-bottom: 1px solid #334155; + flex-shrink: 0; + flex-wrap: wrap; +} +.stat { display: flex; align-items: center; gap: 0.4rem; font-size: 0.82rem; color: #94a3b8; } +.stat-value { font-weight: 700; font-size: 1rem; color: #f1f5f9; } +.skill-bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; flex: 1; min-width: 150px; max-width: 300px; } +.skill-bar-segment { transition: width 0.5s ease; } +.skill-legend { display: flex; gap: 0.75rem; flex-wrap: wrap; } +.skill-legend-item { display: flex; align-items: center; gap: 0.3rem; font-size: 0.75rem; color: #94a3b8; } +.skill-legend-dot { width: 8px; height: 8px; border-radius: 50%; } + +.main { flex: 1; position: relative; overflow: hidden; } + +#pool-view { width: 100%; height: 100%; position: absolute; top: 0; left: 0; } +#pool-canvas { width: 100%; height: 100%; display: block; cursor: default; touch-action: none; } + +.pool-detail { + position: absolute; + background: #1e293b; + border-radius: 0.75rem; + box-shadow: 0 8px 32px rgba(0,0,0,0.4); + padding: 1.25rem; + min-width: 220px; + pointer-events: none; + opacity: 0; + transform: scale(0.9) translateY(8px); + transition: opacity 0.2s, transform 0.2s; + z-index: 50; +} +.pool-detail.visible { opacity: 1; transform: scale(1) translateY(0); pointer-events: auto; } +.pool-detail-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } +.pool-detail-dot { width: 10px; height: 10px; border-radius: 50%; } +.pool-detail-name { font-weight: 600; font-size: 0.95rem; color: #f1f5f9; } +.pool-detail-skill { font-size: 0.82rem; color: #94a3b8; margin-bottom: 0.3rem; } +.pool-detail-hours { font-size: 0.85rem; font-weight: 600; color: #8b5cf6; } +.pool-detail-desc { font-size: 0.8rem; color: #94a3b8; margin-top: 0.35rem; line-height: 1.4; } + +.add-btn { + position: absolute; + bottom: 1.5rem; + right: 1.5rem; + padding: 0.65rem 1.25rem; + background: linear-gradient(135deg, #8b5cf6, #ec4899); + color: #fff; + border: none; + border-radius: 0.5rem; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + box-shadow: 0 4px 16px rgba(139,92,246,0.3); + transition: all 0.2s; + z-index: 20; +} +.add-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(139,92,246,0.4); } + +/* Weaving */ +#weave-view { + width: 100%; height: 100%; + position: absolute; top: 0; left: 0; + flex-direction: row; +} + +.sidebar { + width: 260px; + background: #1e293b; + border-right: 1px solid #334155; + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow: hidden; +} +.sidebar-header { + padding: 0.75rem 1rem; + font-size: 0.85rem; + font-weight: 600; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.04em; + border-bottom: 1px solid #334155; +} +.sidebar-items { flex: 1; overflow-y: auto; padding: 0.5rem; } +.sidebar-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.75rem; + border-radius: 0.5rem; + cursor: grab; + transition: background 0.15s; + margin-bottom: 0.25rem; + touch-action: none; +} +.sidebar-item:hover { background: #334155; } +.sidebar-item.used { opacity: 0.35; pointer-events: none; } +.sidebar-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } +.sidebar-item-info { flex: 1; min-width: 0; } +.sidebar-item-name { font-size: 0.82rem; font-weight: 600; color: #f1f5f9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sidebar-item-meta { font-size: 0.72rem; color: #64748b; } + +.sidebar-section { + padding: 0.75rem 1rem; + font-size: 0.78rem; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; + border-top: 1px solid #334155; + margin-top: 0.25rem; +} +.sidebar-task { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.75rem; + border-radius: 0.5rem; + cursor: grab; + transition: background 0.15s; + margin: 0 0.5rem 0.25rem; + touch-action: none; +} +.sidebar-task:hover { background: #334155; } +.sidebar-task-icon { + width: 24px; height: 24px; + border-radius: 0.375rem; + background: linear-gradient(135deg, #8b5cf6, #ec4899); + display: flex; align-items: center; justify-content: center; + color: #fff; font-size: 0.7rem; flex-shrink: 0; +} + +.canvas-wrap { flex: 1; position: relative; overflow: hidden; background: #0f172a; } +#weave-svg { width: 100%; height: 100%; display: block; touch-action: none; } + +.node-group { cursor: grab; } +.node-group:active { cursor: grabbing; } +.node-rect { fill: #1e293b; stroke: #334155; stroke-width: 1; transition: stroke 0.15s; } +.node-group:hover .node-rect { stroke: #8b5cf6; } +.port { cursor: crosshair; transition: r 0.15s; touch-action: none; } +.port:hover { r: 7; } +.connection-line { fill: none; stroke: #475569; stroke-width: 2; pointer-events: none; } +.connection-line.active { stroke: #8b5cf6; stroke-width: 2.5; } +.temp-connection { fill: none; stroke: #8b5cf6; stroke-width: 2; stroke-dasharray: 6 4; pointer-events: none; opacity: 0.6; } +.task-node .node-rect { stroke: #8b5cf6; stroke-width: 1.5; } +.task-node.ready .node-rect { stroke: #10b981; stroke-width: 2; } + +.exec-btn-rect { cursor: pointer; transition: opacity 0.15s; } +.exec-btn-rect:hover { opacity: 0.85; } + +/* Modals & overlays */ +.modal-overlay, .exec-overlay, .task-edit-overlay { + position: absolute; inset: 0; + background: rgba(0,0,0,0.7); + z-index: 200; + display: none; + align-items: center; + justify-content: center; +} +.modal-overlay.visible, .exec-overlay.visible, .task-edit-overlay.visible { display: flex; } + +.modal { + background: #1e293b; + border-radius: 1rem; + padding: 1.75rem; + width: 380px; + max-width: 90vw; + box-shadow: 0 16px 64px rgba(0,0,0,0.5); +} +.modal h3 { font-size: 1.1rem; font-weight: 700; color: #f1f5f9; margin-bottom: 1rem; } +.modal-field { margin-bottom: 0.85rem; } +.modal-field label { display: block; font-size: 0.8rem; font-weight: 500; color: #94a3b8; margin-bottom: 0.25rem; } +.modal-field input, .modal-field select { + width: 100%; + padding: 0.55rem 0.75rem; + border: 1px solid #475569; + border-radius: 0.375rem; + font-size: 0.9rem; + outline: none; + transition: border-color 0.15s; + background: #0f172a; + color: #e2e8f0; +} +.modal-field input:focus, .modal-field select:focus { border-color: #8b5cf6; } +.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1.25rem; } +.modal-cancel { + padding: 0.5rem 1rem; + border: 1px solid #334155; + border-radius: 0.375rem; + background: #1e293b; + color: #94a3b8; + font-size: 0.85rem; + cursor: pointer; +} +.modal-submit { + padding: 0.5rem 1.25rem; + background: linear-gradient(135deg, #8b5cf6, #ec4899); + color: #fff; + border: none; + border-radius: 0.375rem; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; +} + +/* Exec panel */ +.exec-overlay { z-index: 300; } +.exec-panel { + background: #1e293b; + border-radius: 1rem; + width: 520px; + max-width: 95vw; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 16px 64px rgba(0,0,0,0.5); +} +.exec-panel-header { + padding: 1.5rem 1.75rem 1rem; + border-bottom: 1px solid #334155; +} +.exec-panel-header h2 { font-size: 1.15rem; font-weight: 700; color: #f1f5f9; margin-bottom: 0.2rem; } +.exec-panel-header p { font-size: 0.85rem; color: #64748b; } +.exec-panel-badge { + display: inline-block; + padding: 0.15rem 0.6rem; + border-radius: 1rem; + background: rgba(16,185,129,0.15); + color: #10b981; + font-size: 0.75rem; + font-weight: 600; + margin-left: 0.5rem; + vertical-align: middle; +} +.exec-steps { padding: 0.75rem 1.75rem 1.5rem; } +.exec-step { + display: flex; + gap: 1rem; + padding: 1rem 0.5rem; + border-bottom: 1px solid #1e293b; + align-items: flex-start; + cursor: pointer; + transition: background 0.1s; + border-radius: 0.5rem; +} +.exec-step:hover { background: #0f172a; } +.exec-step:last-child { border-bottom: none; } +.exec-step.active .exec-step-num { background: linear-gradient(135deg, #8b5cf6, #ec4899); color: #fff; } +.exec-step.done .exec-step-num { background: #10b981; color: #fff; } +.exec-step-num { + width: 32px; height: 32px; min-width: 32px; + border-radius: 50%; + background: #334155; + color: #64748b; + display: flex; align-items: center; justify-content: center; + font-size: 0.82rem; font-weight: 600; + transition: all 0.2s; +} +.exec-step-content { flex: 1; } +.exec-step-title { font-size: 0.92rem; font-weight: 600; color: #f1f5f9; margin-bottom: 0.15rem; } +.exec-step.done .exec-step-title { color: #10b981; } +.exec-step-desc { font-size: 0.8rem; color: #64748b; line-height: 1.4; } +.exec-panel-footer { + padding: 1rem 1.75rem; + border-top: 1px solid #334155; + display: flex; + justify-content: space-between; + align-items: center; +} +.exec-close { + padding: 0.45rem 1rem; + border: 1px solid #334155; + border-radius: 0.375rem; + background: #1e293b; + color: #94a3b8; + font-size: 0.85rem; + cursor: pointer; +} +.exec-launch { + padding: 0.5rem 1.5rem; + background: linear-gradient(135deg, #10b981, #059669); + color: #fff; + border: none; + border-radius: 0.375rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; +} +.exec-launch:disabled { opacity: 0.5; cursor: not-allowed; } +.exec-progress { display: flex; gap: 0.35rem; align-items: center; font-size: 0.78rem; color: #64748b; } +.exec-progress-bar { width: 80px; height: 4px; background: #334155; border-radius: 2px; overflow: hidden; } +.exec-progress-fill { height: 100%; background: linear-gradient(90deg, #8b5cf6, #10b981); border-radius: 2px; transition: width 0.3s; } + +/* Task editor */ +.task-edit-overlay { z-index: 250; } +.task-edit-panel { + background: #1e293b; + border-radius: 1rem; + width: 480px; + max-width: 95vw; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 16px 64px rgba(0,0,0,0.5); +} +.task-edit-header { padding: 1.25rem 1.5rem 0.75rem; border-bottom: 1px solid #334155; } +.task-edit-header h3 { font-size: 1.05rem; font-weight: 700; color: #f1f5f9; } +.task-edit-body { padding: 1rem 1.5rem; } +.task-edit-field { margin-bottom: 0.85rem; } +.task-edit-field label { display: block; font-size: 0.78rem; font-weight: 500; color: #94a3b8; margin-bottom: 0.25rem; } +.task-edit-field input, .task-edit-field textarea { + width: 100%; + padding: 0.5rem 0.7rem; + border: 1px solid #475569; + border-radius: 0.375rem; + font-size: 0.85rem; + font-family: inherit; + outline: none; + background: #0f172a; + color: #e2e8f0; +} +.task-edit-field input:focus, .task-edit-field textarea:focus { border-color: #8b5cf6; } +.task-edit-field textarea { resize: vertical; min-height: 70px; } +.task-edit-footer { + padding: 0.75rem 1.5rem; + border-top: 1px solid #334155; + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +@media (max-width: 768px) { + .sidebar { width: 200px; } + .exec-panel { width: 95vw; } + .task-edit-panel { width: 95vw; } +} +@media (max-width: 640px) { + .sidebar { width: 180px; } +} +`; + +customElements.define('folk-timebank-app', FolkTimebankApp); diff --git a/modules/rtime/components/rtime.css b/modules/rtime/components/rtime.css new file mode 100644 index 0000000..a238c7b --- /dev/null +++ b/modules/rtime/components/rtime.css @@ -0,0 +1,7 @@ +/* rTime module — minimal external CSS (most styling in shadow DOM) */ +folk-timebank-app { + display: flex; + flex-direction: column; + height: calc(100vh - var(--rs-header-height, 56px)); + min-height: 0; +} diff --git a/modules/rtime/landing.ts b/modules/rtime/landing.ts new file mode 100644 index 0000000..3af1a80 --- /dev/null +++ b/modules/rtime/landing.ts @@ -0,0 +1,296 @@ +/** + * rTime landing page — community-based timebanking, commitment pooling, + * and emergent collaboration through shared hour ledgers. + */ +export function renderLanding(): string { + return ` + +
+ + Part of the rSpace Ecosystem + +

+ Community
Timebanking +

+

+ A community-based ledger for pooling time commitments + and weaving them into coordinated action. Every hour pledged becomes a visible, + connectable resource that your community can match to real needs. +

+ +
+ + +
+
+
+ ELI5 +

+ What is Timebanking? +

+

+ A community-based ledger where + everyone's hour is worth the same. + Members pledge time, match skills to needs, and build trust through mutual aid. +

+
+ +
+ +
+
+
+ 1h +
+

Commitment Pooling

+
+

+ Members pledge hours with a skill category. Each pledge becomes an orb in the pool — + visible, weighted by hours, colored by skill. + See your community's capacity at a glance. +

+
+ + +
+
+
+ +
+

Community Ledger

+
+

+ Every hour is equal: one hour of facilitation = one hour of tech work. + The ledger tracks pledges, fulfillments, and transfers without money. + Built on CRDTs — works offline, syncs everywhere. +

+
+ + +
+
+
+ +
+

Emergent Collaboration

+
+

+ Projects emerge from the pool: when enough skills converge on a task, the team + self-assembles. No top-down assignment needed. + Coordination that grows from the bottom up. +

+
+
+
+
+ + +
+
+
+ + How It Works + +

+ Pool → Weave → Execute +

+

+ Three stages turn scattered availability into running projects. +

+
+ +
+ +
+
+
+ 1 +
+
+ Stage 1 +

Commitment Pool

+
+
+

+ Members pledge hours with a skill: facilitation, design, tech, outreach, or logistics. + Each pledge appears as a floating orb in the commitment basket. + The pool gives everyone a live dashboard of available community capacity. +

+
+ + +
+
+
+ 2 +
+
+ Stage 2 +

Weaving Dashboard

+
+
+

+ Drag commitments onto an SVG canvas alongside task templates. Draw wires from + commitment ports to task skill slots. The progress bar fills as skills match. + When all needs are met, the task node glows green. +

+
+ + +
+
+
+ 3 +
+
+ Stage 3 +

Execute & Launch

+
+
+

+ Click Execute Project to open a step-by-step + launch panel: set up space, create comms channels, prep docs, and confirm roles. + Track progress as your community's woven commitments become reality. +

+
+
+
+
+ + +
+
+
+

+ Why Community-Based Ledgers? +

+

+ Traditional volunteering is invisible. Timebanking makes every contribution visible, + valued, and connectable. +

+
+ +
+ +
+
+ +

Without Timebanking

+
+
    +
  • Volunteer hours vanish — no record, no recognition
  • +
  • Same few people carry the load while others can't find where to help
  • +
  • Projects stall because coordinators can't see available capacity
  • +
  • Skills go unmatched to needs
  • +
+
+ + +
+
+ +

With rTime

+
+
    +
  • Every pledge is visible — orbs in a shared basket
  • +
  • Skill + hours + name = a connectable resource
  • +
  • Projects self-assemble when enough capacity converges
  • +
  • All data persists locally via CRDT — works offline
  • +
+
+
+
+
+ + +
+
+
+

+ Built for Real Communities +

+
+ +
+
+
+ 🧺 +
+

Visual Pool

+

Physics-based orbs show your community's available hours at a glance.

+
+ +
+
+ 🧶 +
+

Wire Editor

+

Drag-and-drop SVG canvas with bezier connections between skills and tasks.

+
+ +
+
+ 🔄 +
+

Cyclos Ready

+

Optional integration with Cyclos for real timebank balances and hour transfers.

+
+ +
+
+ 📴 +
+

Offline-First

+

Automerge CRDTs keep your data local and synced. No internet required to pledge.

+
+
+
+
+ + +
+
+
+ + Join the rSpace Ecosystem + +

+ Ready to weave your community's time? +

+

+ Create a Space and invite members to pledge their hours. Match skills to projects + and launch coordinated community action — all on a community-based ledger + that values every hour equally. +

+ +
+
+
+ +`; +} diff --git a/modules/rtime/mod.ts b/modules/rtime/mod.ts new file mode 100644 index 0000000..e26cf93 --- /dev/null +++ b/modules/rtime/mod.ts @@ -0,0 +1,418 @@ +/** + * rTime module — timebank commitment pool & weaving dashboard. + * + * Visualize community hour pledges as floating orbs in a basket, + * then weave commitments into tasks on an SVG canvas. Optional + * Cyclos integration for real timebank balances. + * + * All state stored in Automerge documents via SyncServer. + * Doc layout: + * {space}:rtime:commitments → CommitmentsDoc + * {space}:rtime:tasks → TasksDoc + */ + +import { Hono } from "hono"; +import * as Automerge from '@automerge/automerge'; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; +import { verifyToken, extractToken } from "../../server/auth"; +import { renderLanding } from "./landing"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { + commitmentsSchema, tasksSchema, + commitmentsDocId, tasksDocId, +} from './schemas'; +import type { + CommitmentsDoc, TasksDoc, + Commitment, Task, Connection, ExecState, Skill, +} from './schemas'; + +const routes = new Hono(); + +// ── SyncServer ref (set during onInit) ── +let _syncServer: SyncServer | null = null; + +// ── Automerge helpers ── + +function ensureCommitmentsDoc(space: string): CommitmentsDoc { + const docId = commitmentsDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init commitments', (d) => { + const init = commitmentsSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +function ensureTasksDoc(space: string): TasksDoc { + const docId = tasksDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init tasks', (d) => { + const init = tasksSchema.init(); + Object.assign(d, init); + d.meta.spaceSlug = space; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +function newId(): string { + return crypto.randomUUID(); +} + +// ── Cyclos proxy config ── +const CYCLOS_URL = process.env.CYCLOS_URL || ''; +const CYCLOS_API_KEY = process.env.CYCLOS_API_KEY || ''; + +function cyclosHeaders(): Record { + const h: Record = { 'Content-Type': 'application/json' }; + if (CYCLOS_API_KEY) h['Authorization'] = `Basic ${Buffer.from(CYCLOS_API_KEY).toString('base64')}`; + return h; +} + +// ── Demo seeding ── + +const DEMO_COMMITMENTS: Omit[] = [ + { memberName: 'Maya Chen', hours: 3, skill: 'facilitation', desc: 'Circle facilitation for group sessions' }, + { memberName: 'Jordan Rivera', hours: 2, skill: 'design', desc: 'Event poster and social media graphics' }, + { memberName: 'Sam Okafor', hours: 4, skill: 'tech', desc: 'Website updates and form setup' }, + { memberName: 'Priya Sharma', hours: 2, skill: 'outreach', desc: 'Community outreach and flyering' }, + { memberName: 'Alex Kim', hours: 1, skill: 'logistics', desc: 'Venue setup and teardown' }, + { memberName: 'Taylor Brooks', hours: 3, skill: 'facilitation', desc: 'Harm reduction education session' }, + { memberName: 'Robin Patel', hours: 2, skill: 'design', desc: 'Printed resource cards' }, + { memberName: 'Casey Morgan', hours: 2, skill: 'logistics', desc: 'Supply procurement and transport' }, +]; + +const DEMO_TASKS: Omit[] = [ + { name: 'Organize Community Event', description: '', needs: { facilitation: 3, design: 2, outreach: 2, logistics: 2 }, links: [], notes: '' }, + { name: 'Run Harm Reduction Workshop', description: '', needs: { facilitation: 4, design: 2, tech: 4, logistics: 1 }, links: [], notes: '' }, +]; + +function seedDemoIfEmpty(space: string = 'demo') { + if (!_syncServer) return; + const existing = _syncServer.getDoc(commitmentsDocId(space)); + if (existing?.meta?.seeded) return; + + ensureCommitmentsDoc(space); + const now = Date.now(); + + _syncServer.changeDoc(commitmentsDocId(space), 'seed commitments', (d) => { + for (const c of DEMO_COMMITMENTS) { + const id = newId(); + d.items[id] = { id, ...c, createdAt: now } as any; + } + (d.meta as any).seeded = true; + }); + + ensureTasksDoc(space); + _syncServer.changeDoc(tasksDocId(space), 'seed tasks', (d) => { + for (const t of DEMO_TASKS) { + const id = newId(); + d.tasks[id] = { id, ...t } as any; + } + }); + + console.log(`[rTime] Demo data seeded for "${space}": ${DEMO_COMMITMENTS.length} commitments, ${DEMO_TASKS.length} tasks`); +} + +// ── Commitments API ── + +routes.get("/api/commitments", (c) => { + const space = c.req.param("space") || "demo"; + ensureCommitmentsDoc(space); + const doc = _syncServer!.getDoc(commitmentsDocId(space))!; + const items = Object.values(doc.items); + return c.json({ commitments: items }); +}); + +routes.post("/api/commitments", 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 { memberName, hours, skill, desc } = body; + if (!memberName || !hours || !skill) return c.json({ error: "memberName, hours, skill required" }, 400); + + const id = newId(); + const now = Date.now(); + ensureCommitmentsDoc(space); + + _syncServer!.changeDoc(commitmentsDocId(space), 'add commitment', (d) => { + d.items[id] = { id, memberName, hours: Math.max(1, Math.min(10, hours)), skill, desc: desc || '', createdAt: now } as any; + }); + + const doc = _syncServer!.getDoc(commitmentsDocId(space))!; + return c.json(doc.items[id], 201); +}); + +routes.delete("/api/commitments/:id", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const id = c.req.param("id"); + ensureCommitmentsDoc(space); + + const doc = _syncServer!.getDoc(commitmentsDocId(space))!; + if (!doc.items[id]) return c.json({ error: "Not found" }, 404); + + _syncServer!.changeDoc(commitmentsDocId(space), 'remove commitment', (d) => { + delete d.items[id]; + }); + + return c.json({ ok: true }); +}); + +// ── Tasks API ── + +routes.get("/api/tasks", (c) => { + const space = c.req.param("space") || "demo"; + ensureTasksDoc(space); + const doc = _syncServer!.getDoc(tasksDocId(space))!; + return c.json({ + tasks: Object.values(doc.tasks), + connections: Object.values(doc.connections), + execStates: Object.values(doc.execStates), + }); +}); + +routes.post("/api/tasks", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { 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 { name, description, needs } = body; + if (!name || !needs) return c.json({ error: "name, needs required" }, 400); + + const id = newId(); + ensureTasksDoc(space); + + _syncServer!.changeDoc(tasksDocId(space), 'add task', (d) => { + d.tasks[id] = { id, name, description: description || '', needs, links: [], notes: '' } as any; + }); + + const doc = _syncServer!.getDoc(tasksDocId(space))!; + return c.json(doc.tasks[id], 201); +}); + +routes.put("/api/tasks/:id", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { 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(); + ensureTasksDoc(space); + + const doc = _syncServer!.getDoc(tasksDocId(space))!; + if (!doc.tasks[id]) return c.json({ error: "Not found" }, 404); + + _syncServer!.changeDoc(tasksDocId(space), 'update task', (d) => { + const t = d.tasks[id]; + if (body.name !== undefined) t.name = body.name; + if (body.description !== undefined) t.description = body.description; + if (body.needs !== undefined) t.needs = body.needs; + if (body.links !== undefined) t.links = body.links; + if (body.notes !== undefined) t.notes = body.notes; + }); + + const updated = _syncServer!.getDoc(tasksDocId(space))!; + return c.json(updated.tasks[id]); +}); + +// ── Connections API ── + +routes.post("/api/connections", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { 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 { fromCommitmentId, toTaskId, skill } = body; + if (!fromCommitmentId || !toTaskId || !skill) return c.json({ error: "fromCommitmentId, toTaskId, skill required" }, 400); + + const id = newId(); + ensureTasksDoc(space); + + _syncServer!.changeDoc(tasksDocId(space), 'add connection', (d) => { + d.connections[id] = { id, fromCommitmentId, toTaskId, skill } as any; + }); + + const doc = _syncServer!.getDoc(tasksDocId(space))!; + return c.json(doc.connections[id], 201); +}); + +routes.delete("/api/connections/:id", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const id = c.req.param("id"); + ensureTasksDoc(space); + + const doc = _syncServer!.getDoc(tasksDocId(space))!; + if (!doc.connections[id]) return c.json({ error: "Not found" }, 404); + + _syncServer!.changeDoc(tasksDocId(space), 'remove connection', (d) => { + delete d.connections[id]; + }); + + return c.json({ ok: true }); +}); + +// ── Exec State API ── + +routes.put("/api/tasks/:id/exec-state", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const taskId = c.req.param("id"); + const body = await c.req.json(); + const { steps, launchedAt } = body; + ensureTasksDoc(space); + + _syncServer!.changeDoc(tasksDocId(space), 'update exec state', (d) => { + if (!d.execStates[taskId]) { + d.execStates[taskId] = { taskId, steps: {}, launchedAt: undefined } as any; + } + if (steps) d.execStates[taskId].steps = steps; + if (launchedAt) d.execStates[taskId].launchedAt = launchedAt; + }); + + const doc = _syncServer!.getDoc(tasksDocId(space))!; + return c.json(doc.execStates[taskId]); +}); + +// ── Cyclos proxy API (graceful no-op when CYCLOS_URL not set) ── + +routes.get("/api/cyclos/members", async (c) => { + if (!CYCLOS_URL) return c.json({ error: "Cyclos not configured", members: [] }); + try { + const resp = await fetch(`${CYCLOS_URL}/api/users?roles=member&fields=id,display,email`, { headers: cyclosHeaders() }); + if (!resp.ok) throw new Error(`Cyclos ${resp.status}`); + const users = await resp.json() as any[]; + const members = await Promise.all(users.map(async (u: any) => { + try { + const balResp = await fetch(`${CYCLOS_URL}/api/${u.id}/accounts`, { headers: cyclosHeaders() }); + const accounts = balResp.ok ? await balResp.json() as any[] : []; + const balance = accounts[0]?.status?.balance || '0'; + return { id: u.id, name: u.display, email: u.email, balance: parseFloat(balance) }; + } catch { + return { id: u.id, name: u.display, email: u.email, balance: 0 }; + } + })); + return c.json({ members }); + } catch (err: any) { + return c.json({ error: 'Failed to fetch from Cyclos', members: [] }, 502); + } +}); + +routes.post("/api/cyclos/commitments", async (c) => { + if (!CYCLOS_URL) return c.json({ error: "Cyclos not configured" }, 501); + const body = await c.req.json(); + const { fromUserId, amount, description } = body; + if (!fromUserId || !amount) return c.json({ error: "fromUserId, amount required" }, 400); + try { + const resp = await fetch(`${CYCLOS_URL}/api/${fromUserId}/payments`, { + method: 'POST', + headers: cyclosHeaders(), + body: JSON.stringify({ type: 'user.toSystem', amount: String(amount), description: description || 'Commitment', subject: 'system' }), + }); + if (!resp.ok) throw new Error(await resp.text()); + const result = await resp.json(); + return c.json(result); + } catch (err: any) { + return c.json({ error: 'Cyclos commitment failed' }, 502); + } +}); + +routes.post("/api/cyclos/transfers", async (c) => { + if (!CYCLOS_URL) return c.json({ error: "Cyclos not configured" }, 501); + const body = await c.req.json(); + const { fromUserId, toUserId, amount, description } = body; + if (!fromUserId || !toUserId || !amount) return c.json({ error: "fromUserId, toUserId, amount required" }, 400); + try { + const resp = await fetch(`${CYCLOS_URL}/api/${fromUserId}/payments`, { + method: 'POST', + headers: cyclosHeaders(), + body: JSON.stringify({ type: 'user.toUser', amount: String(amount), description: description || 'Hour transfer', subject: toUserId }), + }); + if (!resp.ok) throw new Error(await resp.text()); + const result = await resp.json(); + return c.json(result); + } catch (err: any) { + return c.json({ error: 'Cyclos transfer failed' }, 502); + } +}); + +// ── Page routes ── + +routes.get("/", (c) => { + const space = c.req.param("space") || "demo"; + + return c.html(renderShell({ + title: `${space} — rTime | rSpace`, + moduleId: "rtime", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + scripts: ``, + styles: ``, + })); +}); + +// ── Module export ── + +export const timeModule: RSpaceModule = { + id: "rtime", + name: "rTime", + icon: "⏳", + description: "Timebank commitment pool & weaving dashboard", + scoping: { defaultScope: 'space', userConfigurable: false }, + docSchemas: [ + { pattern: '{space}:rtime:commitments', description: 'Commitment pool', init: commitmentsSchema.init }, + { pattern: '{space}:rtime:tasks', description: 'Tasks, connections, exec states', init: tasksSchema.init }, + ], + routes, + landingPage: renderLanding, + seedTemplate: seedDemoIfEmpty, + async onInit(ctx) { + _syncServer = ctx.syncServer; + seedDemoIfEmpty(); + }, + feeds: [ + { + id: "commitments", + name: "Commitments", + kind: "economic", + description: "Hour pledges from community members", + filterable: true, + }, + ], + outputPaths: [ + { path: "commitments", name: "Commitments", icon: "🧺", description: "Community hour pledges" }, + { path: "weave", name: "Weave", icon: "🧶", description: "Task weaving dashboard" }, + ], + onboardingActions: [ + { label: "Pledge Hours", icon: "⏳", description: "Add a commitment to the pool", type: 'create', href: '/{space}/rtime' }, + ], +}; diff --git a/modules/rtime/schemas.ts b/modules/rtime/schemas.ts new file mode 100644 index 0000000..9b4b5d5 --- /dev/null +++ b/modules/rtime/schemas.ts @@ -0,0 +1,138 @@ +/** + * rTime Automerge document schemas. + * + * DocId formats: + * {space}:rtime:commitments → CommitmentsDoc (commitment pool) + * {space}:rtime:tasks → TasksDoc (weaving: tasks, connections, exec states) + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Skill type ── + +export type Skill = 'facilitation' | 'design' | 'tech' | 'outreach' | 'logistics'; + +export const SKILL_COLORS: Record = { + facilitation: '#8b5cf6', + design: '#ec4899', + tech: '#3b82f6', + outreach: '#10b981', + logistics: '#f59e0b', +}; + +export const SKILL_LABELS: Record = { + facilitation: 'Facilitation', + design: 'Design', + tech: 'Tech', + outreach: 'Outreach', + logistics: 'Logistics', +}; + +// ── Commitment ── + +export interface Commitment { + id: string; + memberName: string; + hours: number; // 1–10 + skill: Skill; + desc: string; + cyclosMemberId?: string; + createdAt: number; +} + +// ── Task / Connection / ExecState ── + +export interface Task { + id: string; + name: string; + description: string; + needs: Record; // skill → hours needed + links: { label: string; url: string }[]; + notes: string; +} + +export interface Connection { + id: string; + fromCommitmentId: string; + toTaskId: string; + skill: string; +} + +export interface ExecState { + taskId: string; + steps: Record; + launchedAt?: number; +} + +// ── Documents ── + +export interface CommitmentsDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + seeded?: boolean; + }; + items: Record; +} + +export interface TasksDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + tasks: Record; + connections: Record; + execStates: Record; +} + +// ── DocId helpers ── + +export function commitmentsDocId(space: string) { + return `${space}:rtime:commitments` as const; +} + +export function tasksDocId(space: string) { + return `${space}:rtime:tasks` as const; +} + +// ── Schema registrations ── + +export const commitmentsSchema: DocSchema = { + module: 'rtime', + collection: 'commitments', + version: 1, + init: (): CommitmentsDoc => ({ + meta: { + module: 'rtime', + collection: 'commitments', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + items: {}, + }), +}; + +export const tasksSchema: DocSchema = { + module: 'rtime', + collection: 'tasks', + version: 1, + init: (): TasksDoc => ({ + meta: { + module: 'rtime', + collection: 'tasks', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + tasks: {}, + connections: {}, + execStates: {}, + }), +}; diff --git a/server/index.ts b/server/index.ts index bd9d23e..8aa2b04 100644 --- a/server/index.ts +++ b/server/index.ts @@ -82,6 +82,7 @@ import { scheduleModule } from "../modules/rschedule/mod"; import { bnbModule } from "../modules/rbnb/mod"; import { vnbModule } from "../modules/rvnb/mod"; import { crowdsurfModule } from "../modules/crowdsurf/mod"; +import { timeModule } from "../modules/rtime/mod"; import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces"; import type { SpaceRoleString } from "./spaces"; import { renderShell, renderSubPageInfo, renderModuleLanding, renderOnboarding, setFragmentMode } from "./shell"; @@ -126,6 +127,7 @@ registerModule(chatsModule); registerModule(bnbModule); registerModule(vnbModule); registerModule(crowdsurfModule); +registerModule(timeModule); registerModule(designModule); // Scribus DTP + AI design agent // De-emphasized modules (bottom of menu) registerModule(forumModule); diff --git a/server/shell.ts b/server/shell.ts index 94c8104..edb4265 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -48,6 +48,7 @@ const FAVICON_BADGE_MAP: Record = { crowdsurf: { badge: "r🏄", color: "#fde68a" }, rids: { badge: "r🪪", color: "#6ee7b7" }, rdesign: { badge: "r🎨", color: "#7c3aed" }, + rtime: { badge: "r⏳", color: "#a78bfa" }, rstack: { badge: "r✨", color: "#c4b5fd" }, }; diff --git a/vite.config.ts b/vite.config.ts index 9d07076..7119ab4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -391,6 +391,33 @@ export default defineConfig({ resolve(__dirname, "dist/modules/crowdsurf/crowdsurf.css"), ); + // Build rtime module component + await wasmBuild({ + configFile: false, + root: resolve(__dirname, "modules/rtime/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rtime"), + lib: { + entry: resolve(__dirname, "modules/rtime/components/folk-timebank-app.ts"), + formats: ["es"], + fileName: () => "folk-timebank-app.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-timebank-app.js", + }, + }, + }, + }); + + // Copy rtime CSS + mkdirSync(resolve(__dirname, "dist/modules/rtime"), { recursive: true }); + copyFileSync( + resolve(__dirname, "modules/rtime/components/rtime.css"), + resolve(__dirname, "dist/modules/rtime/rtime.css"), + ); + // Build flows module components const flowsAlias = { "../lib/types": resolve(__dirname, "modules/rflows/lib/types.ts"),