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"),