diff --git a/modules/rtime/components/folk-timebank-app.ts b/modules/rtime/components/folk-timebank-app.ts index eb7a4b5..eab38e8 100644 --- a/modules/rtime/components/folk-timebank-app.ts +++ b/modules/rtime/components/folk-timebank-app.ts @@ -63,6 +63,9 @@ interface Wire { from: string; to: string; skill: string; + hours: number; + status: 'proposed' | 'committed'; + connectionId?: string; } // ── Orb class ── @@ -309,7 +312,7 @@ class FolkTimebankApp extends HTMLElement { // Exec state private execStepStates: Record> = {}; - private _restoredConnections: { fromCommitmentId: string; toTaskId: string; skill: string }[] = []; + private _restoredConnections: { id: string; fromCommitmentId: string; toTaskId: string; skill: string; hours: number; status: 'proposed' | 'committed' }[] = []; private _currentExecTaskId: string | null = null; private _cyclosMembers: { id: string; name: string; balance: number }[] = []; private _theme: 'dark' | 'light' = 'dark'; @@ -384,11 +387,14 @@ class FolkTimebankApp extends HTMLElement { if (tResp.ok) { const tData = await tResp.json(); this.tasks = tData.tasks || []; - // Restore connections + // Restore connections (with hours + status) this._restoredConnections = (tData.connections || []).map((cn: any) => ({ + id: cn.id, fromCommitmentId: cn.fromCommitmentId, toTaskId: cn.toTaskId, skill: cn.skill, + hours: cn.hours || 0, + status: cn.status || 'proposed', })); // Restore exec states for (const es of (tData.execStates || [])) { @@ -753,12 +759,31 @@ class FolkTimebankApp extends HTMLElement { this.basketR = Math.min(this.poolW, this.poolH) * 0.42; } + private availableHours(commitmentId: string): number { + const commitment = this.commitments.find(c => c.id === commitmentId); + if (!commitment) return 0; + const used = this.connections + .filter(w => w.from === 'cn-' + commitmentId) + .reduce((sum, w) => sum + (w.hours || 0), 0); + return commitment.hours - used; + } + private buildOrbs() { - this.orbs = this.commitments.map(c => { - const orb = new Orb(c, this.basketCX, this.basketCY, this.basketR); - orb.statusRing = this.memberIntentStatus.get(c.memberName) || null; - return orb; - }); + this.orbs = this.commitments + .filter(c => { + const avail = this.availableHours(c.id); + return avail > 0; + }) + .map(c => { + const avail = this.availableHours(c.id); + // Create a display commitment with available hours for orb sizing + const displayC = avail < c.hours ? { ...c, hours: avail } : c; + const orb = new Orb(displayC, this.basketCX, this.basketCY, this.basketR); + // Keep original commitment ref for drag operations + (orb as any)._originalCommitment = c; + orb.statusRing = this.memberIntentStatus.get(c.memberName) || null; + return orb; + }); } private resolveCollisions() { @@ -1174,10 +1199,26 @@ class FolkTimebankApp extends HTMLElement { const idx = skills.indexOf(conn.skill); if (idx >= 0) y2 = to.y + TASK_H_BASE + idx * TASK_ROW + TASK_ROW / 2; } + const isCommitted = conn.status === 'committed'; const p = ns('path'); p.setAttribute('d', bezier(x1, y1, x2, y2)); p.setAttribute('class', 'connection-line active'); + p.setAttribute('stroke', isCommitted ? '#10b981' : '#f59e0b'); + p.setAttribute('stroke-width', '2'); + if (!isCommitted) p.setAttribute('stroke-dasharray', '6 4'); this.connectionsLayer.appendChild(p); + + // Hour label at midpoint + const mx = (x1 + x2) / 2, my = (y1 + y2) / 2; + const label = ns('text') as SVGTextElement; + label.setAttribute('x', String(mx)); + label.setAttribute('y', String(my - 6)); + label.setAttribute('text-anchor', 'middle'); + label.setAttribute('font-size', '10'); + label.setAttribute('font-weight', '600'); + label.setAttribute('fill', isCommitted ? '#10b981' : '#f59e0b'); + label.textContent = conn.hours + 'h ' + (isCommitted ? '\u2713' : '\u23f3'); + this.connectionsLayer.appendChild(label); }); } @@ -1248,6 +1289,40 @@ class FolkTimebankApp extends HTMLElement { port.style.pointerEvents = 'none'; g.appendChild(port); + // Approval buttons for proposed connections (visible to commitment owner) + const proposedWires = this.connections.filter(w => w.from === node.id && w.status === 'proposed' && w.connectionId); + if (proposedWires.length > 0) { + const btnY = cy + hr * 0.55; + proposedWires.forEach((pw, idx) => { + const by = btnY + idx * 22; + // Approve button + const approveR = ns('rect'); + approveR.setAttribute('x', String(cx - 36)); approveR.setAttribute('y', String(by)); + approveR.setAttribute('width', '32'); approveR.setAttribute('height', '18'); + approveR.setAttribute('rx', '4'); approveR.setAttribute('fill', '#10b981'); + approveR.setAttribute('class', 'approve-btn'); + approveR.setAttribute('data-connection-id', pw.connectionId!); + approveR.style.cursor = 'pointer'; + g.appendChild(approveR); + const appT = svgText('\u2713', cx - 20, by + 13, 11, '#fff', '700', 'middle'); + appT.style.pointerEvents = 'none'; + g.appendChild(appT); + + // Decline button + const declineR = ns('rect'); + declineR.setAttribute('x', String(cx + 4)); declineR.setAttribute('y', String(by)); + declineR.setAttribute('width', '32'); declineR.setAttribute('height', '18'); + declineR.setAttribute('rx', '4'); declineR.setAttribute('fill', '#ef4444'); + declineR.setAttribute('class', 'decline-btn'); + declineR.setAttribute('data-connection-id', pw.connectionId!); + declineR.style.cursor = 'pointer'; + g.appendChild(declineR); + const decT = svgText('\u2717', cx + 20, by + 13, 11, '#fff', '700', 'middle'); + decT.style.pointerEvents = 'none'; + g.appendChild(decT); + }); + } + } else if (node.type === 'task') { const t = node.data as TaskData; const skills = Object.keys(t.needs); @@ -1294,7 +1369,11 @@ class FolkTimebankApp extends HTMLElement { skills.forEach((skill, i) => { const ry = TASK_H_BASE + i * TASK_ROW; const needed = t.needs[skill]; - const ful = (t.fulfilled || {})[skill] || 0; + // Compute proposed/committed hours for this skill on this task + const skillWires = this.connections.filter(w => w.to === node.id && w.skill === skill); + const committed = skillWires.filter(w => w.status === 'committed').reduce((s, w) => s + w.hours, 0); + const proposed = skillWires.filter(w => w.status === 'proposed').reduce((s, w) => s + w.hours, 0); + const ful = committed + proposed; const done = ful >= needed; const portHit = ns('circle'); @@ -1303,10 +1382,13 @@ class FolkTimebankApp extends HTMLElement { portHit.setAttribute('class', 'port'); portHit.setAttribute('data-node', node.id); portHit.setAttribute('data-port', 'input'); portHit.setAttribute('data-skill', skill); g.appendChild(portHit); + + // Port fill: green segment for committed, amber for proposed + const portColor = committed >= needed ? '#10b981' : proposed > 0 ? '#f59e0b' : (SKILL_COLORS[skill] || '#888'); 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('fill', portColor); port.setAttribute('stroke', '#fff'); port.setAttribute('stroke-width', '2'); port.style.pointerEvents = 'none'; g.appendChild(port); @@ -1316,9 +1398,20 @@ class FolkTimebankApp extends HTMLElement { 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')); + // Build label showing committed/proposed/needed + let lbl: string; + if (committed > 0 && proposed > 0) { + lbl = (SKILL_LABELS[skill] || skill) + ': ' + committed + 'c+' + proposed + 'p/' + needed + 'hr'; + } else if (committed > 0) { + lbl = (SKILL_LABELS[skill] || skill) + ': ' + committed + '/' + needed + 'hr \u2713'; + } else if (proposed > 0) { + lbl = (SKILL_LABELS[skill] || skill) + ': ' + proposed + '/' + needed + 'hr \u23f3'; + } else { + lbl = (SKILL_LABELS[skill] || skill) + ': 0/' + needed + 'hr'; + } + const lblColor = committed >= needed ? '#10b981' : proposed > 0 ? '#f59e0b' : '#94a3b8'; + g.appendChild(svgText(lbl, 30, ry + TASK_ROW / 2 + 4, 11, lblColor, done ? '600' : '400')); + if (committed >= needed) g.appendChild(svgText('\u2713', node.w - 16, ry + TASK_ROW / 2 + 4, 13, '#10b981', '600', 'middle')); }); if (ready) { @@ -1368,6 +1461,18 @@ class FolkTimebankApp extends HTMLElement { return; } + // Approve/decline buttons on commitment hexagons + if ((e.target as Element).classList.contains('approve-btn')) { + const connId = (e.target as SVGElement).getAttribute('data-connection-id'); + if (connId) this.handleApprove(connId); + return; + } + if ((e.target as Element).classList.contains('decline-btn')) { + const connId = (e.target as SVGElement).getAttribute('data-connection-id'); + if (connId) this.handleDecline(connId); + return; + } + const port = (e.target as Element).closest('.port') as SVGElement; if (port) { e.preventDefault(); @@ -1465,15 +1570,22 @@ class FolkTimebankApp extends HTMLElement { 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; - this.persistConnection(fromId, toId, skill); - const nowReady = this.isTaskReady(tNode); - this.renderAll(); - this.rebuildSidebar(); - if (nowReady) setTimeout(() => this.openExecPanel(tNode.id), 600); + if (!this.connections.find(c => c.from === fromId && c.to === toId && c.skill === skill)) { + const commitId = fromId.replace(/^cn-/, ''); + const available = this.availableHours(commitId); + const needed = (tNode.data.needs[skill] || 0) - ((tNode.data.fulfilled || {})[skill] || 0); + const allocate = Math.min(available, Math.max(0, needed)); + if (allocate > 0) { + this.connections.push({ from: fromId, to: toId, skill, hours: allocate, status: 'proposed' }); + if (!tNode.data.fulfilled) tNode.data.fulfilled = {}; + tNode.data.fulfilled[skill] = (tNode.data.fulfilled[skill] || 0) + allocate; + this.persistConnection(fromId, toId, skill, allocate); + this.buildOrbs(); + const nowReady = this.isTaskReady(tNode); + this.renderAll(); + this.rebuildSidebar(); + if (nowReady) setTimeout(() => this.openExecPanel(tNode.id), 600); + } } } } @@ -1603,26 +1715,39 @@ class FolkTimebankApp extends HTMLElement { if (wrap && this.draggingOrb) { const wr = wrap.getBoundingClientRect(); if (ev.clientX >= wr.left && ev.clientX <= wr.right && ev.clientY >= wr.top && ev.clientY <= wr.bottom) { - const c = this.draggingOrb.c; + // Use original commitment (not the display-hours version) + const c = (this.draggingOrb as any)._originalCommitment || this.draggingOrb.c; const pt = this.screenToCanvas(ev.clientX, ev.clientY); - if (!this.weaveNodes.find(n => n.id === 'cn-' + c.id)) { - this.weaveNodes.push(this.mkCommitNode(c, pt.x - NODE_W / 2, pt.y - NODE_H / 2)); - // Hit-test: did we drop on an unfulfilled task port? - const dropNode = this.hitTestTaskPort(pt.x, pt.y, c.skill); - if (dropNode) { + // Hit-test: did we drop on an unfulfilled task port? + const dropNode = this.hitTestTaskPort(pt.x, pt.y, c.skill); + if (dropNode) { + const available = this.availableHours(c.id); + const needed = (dropNode.data.needs[c.skill] || 0) - ((dropNode.data.fulfilled || {})[c.skill] || 0); + const allocate = Math.min(available, Math.max(0, needed)); + if (allocate > 0) { const fromId = 'cn-' + c.id; - if (!this.connections.find(w => w.from === fromId && w.to === dropNode.id)) { - this.connections.push({ from: fromId, to: dropNode.id, skill: c.skill }); + // Ensure commitment node exists on canvas + if (!this.weaveNodes.find(n => n.id === fromId)) { + this.weaveNodes.push(this.mkCommitNode(c, pt.x - NODE_W / 2, pt.y - NODE_H / 2)); + } + if (!this.connections.find(w => w.from === fromId && w.to === dropNode.id && w.skill === c.skill)) { + this.connections.push({ from: fromId, to: dropNode.id, skill: c.skill, hours: allocate, status: 'proposed' }); if (!dropNode.data.fulfilled) dropNode.data.fulfilled = {}; - dropNode.data.fulfilled[c.skill] = (dropNode.data.fulfilled[c.skill] || 0) + c.hours; - this.persistConnection(fromId, dropNode.id, c.skill); + dropNode.data.fulfilled[c.skill] = (dropNode.data.fulfilled[c.skill] || 0) + allocate; + this.persistConnection(fromId, dropNode.id, c.skill, allocate); } } - - this.renderAll(); - this.rebuildSidebar(); + } else { + // Dropped on open canvas — place commitment node without connection + if (!this.weaveNodes.find(n => n.id === 'cn-' + c.id)) { + this.weaveNodes.push(this.mkCommitNode(c, pt.x - NODE_W / 2, pt.y - NODE_H / 2)); + } } + + this.buildOrbs(); + this.renderAll(); + this.rebuildSidebar(); } } this.cancelOrbDrag(); @@ -1690,25 +1815,75 @@ class FolkTimebankApp extends HTMLElement { const fNode = this.weaveNodes.find(n => n.id === fromNodeId); const tNode = this.weaveNodes.find(n => n.id === toNodeId); if (!fNode || !tNode) continue; - if (this.connections.find(c => c.from === fromNodeId && c.to === toNodeId)) continue; - this.connections.push({ from: fromNodeId, to: toNodeId, skill: rc.skill }); + if (this.connections.find(c => c.from === fromNodeId && c.to === toNodeId && c.skill === rc.skill)) continue; + this.connections.push({ from: fromNodeId, to: toNodeId, skill: rc.skill, hours: rc.hours, status: rc.status, connectionId: rc.id }); if (!tNode.data.fulfilled) tNode.data.fulfilled = {}; - tNode.data.fulfilled[rc.skill] = (tNode.data.fulfilled[rc.skill] || 0) + (fNode.data.hours || 0); + tNode.data.fulfilled[rc.skill] = (tNode.data.fulfilled[rc.skill] || 0) + rc.hours; } this._restoredConnections = []; this.renderAll(); this.rebuildSidebar(); } - private async persistConnection(from: string, to: string, skill: string) { + private async persistConnection(from: string, to: string, skill: string, hours: number): Promise { const fromCommitmentId = from.replace(/^cn-/, ''); try { - await fetch(`${this.getApiBase()}/api/connections`, { + const res = await fetch(`${this.getApiBase()}/api/connections`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, - body: JSON.stringify({ fromCommitmentId, toTaskId: to, skill }), + body: JSON.stringify({ fromCommitmentId, toTaskId: to, skill, hours }), }); + if (res.ok) { + const data = await res.json(); + // Update local wire with server-assigned id and status + const wire = this.connections.find(w => w.from === from && w.to === to && w.skill === skill && !w.connectionId); + if (wire) { + wire.connectionId = data.id; + wire.status = data.status || 'proposed'; + } + return data; + } } catch { /* offline */ } + return null; + } + + private async patchConnection(connectionId: string, status: 'committed' | 'declined') { + try { + const res = await fetch(`${this.getApiBase()}/api/connections/${connectionId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, + body: JSON.stringify({ status }), + }); + if (res.ok) return await res.json(); + } catch { /* offline */ } + return null; + } + + private async handleApprove(connectionId: string) { + const wire = this.connections.find(w => w.connectionId === connectionId); + if (!wire) return; + const result = await this.patchConnection(connectionId, 'committed'); + if (result) { + wire.status = 'committed'; + this.renderAll(); + } + } + + private async handleDecline(connectionId: string) { + const wire = this.connections.find(w => w.connectionId === connectionId); + if (!wire) return; + const result = await this.patchConnection(connectionId, 'declined'); + if (result) { + // Remove wire and restore fulfilled hours + const tNode = this.weaveNodes.find(n => n.id === wire.to); + if (tNode && tNode.data.fulfilled) { + tNode.data.fulfilled[wire.skill] = Math.max(0, (tNode.data.fulfilled[wire.skill] || 0) - wire.hours); + } + this.connections = this.connections.filter(w => w.connectionId !== connectionId); + this.buildOrbs(); + this.renderAll(); + this.rebuildSidebar(); + } } private async persistExecState(taskId: string) { @@ -2606,6 +2781,8 @@ const CSS_TEXT = ` .exec-btn-rect { cursor: pointer; transition: opacity 0.15s; } .exec-btn-rect:hover { opacity: 0.85; } +.approve-btn, .decline-btn { cursor: pointer; transition: opacity 0.15s; } +.approve-btn:hover, .decline-btn:hover { opacity: 0.8; } /* Modals & overlays */ .modal-overlay, .exec-overlay, .task-edit-overlay { diff --git a/modules/rtime/mod.ts b/modules/rtime/mod.ts index 1da8973..f915b6e 100644 --- a/modules/rtime/mod.ts +++ b/modules/rtime/mod.ts @@ -254,28 +254,39 @@ routes.post("/api/connections", async (c) => { const space = c.req.param("space") || "demo"; const body = await c.req.json(); - const { fromCommitmentId, toTaskId, skill } = body; + const { fromCommitmentId, toTaskId, skill, hours } = body; if (!fromCommitmentId || !toTaskId || !skill) return c.json({ error: "fromCommitmentId, toTaskId, skill required" }, 400); + if (typeof hours !== 'number' || hours <= 0) return c.json({ error: "hours must be a positive number" }, 400); const id = newId(); ensureTasksDoc(space); ensureCommitmentsDoc(space); + // Validate: hours <= commitment's available hours + const cDoc = _syncServer!.getDoc(commitmentsDocId(space)); + const commitment = cDoc?.items?.[fromCommitmentId]; + if (!commitment) return c.json({ error: "Commitment not found" }, 404); + const tDoc = _syncServer!.getDoc(tasksDocId(space))!; + const usedHours = Object.values(tDoc.connections) + .filter(cn => cn.fromCommitmentId === fromCommitmentId) + .reduce((sum, cn) => sum + (cn.hours || 0), 0); + if (hours > commitment.hours - usedHours) { + return c.json({ error: "Requested hours exceed available hours" }, 400); + } + _syncServer!.changeDoc(tasksDocId(space), 'add connection', (d) => { - d.connections[id] = { id, fromCommitmentId, toTaskId, skill } as any; + d.connections[id] = { id, fromCommitmentId, toTaskId, skill, hours, status: 'proposed' } as any; }); // Notify commitment owner that their time was requested - const cDoc = _syncServer!.getDoc(commitmentsDocId(space)); - const tDoc = _syncServer!.getDoc(tasksDocId(space))!; - const commitment = cDoc?.items?.[fromCommitmentId]; - const task = tDoc.tasks?.[toTaskId]; + const updatedTDoc = _syncServer!.getDoc(tasksDocId(space))!; + const task = updatedTDoc.tasks?.[toTaskId]; if (commitment?.ownerDid && commitment.ownerDid !== claims.did) { notify({ userDid: commitment.ownerDid, category: 'module', eventType: 'commitment_requested', - title: `Your ${commitment.hours}hr ${skill} commitment was requested`, + title: `${hours}hr of your ${commitment.hours}hr ${skill} commitment was requested`, body: task ? `Task: ${task.name}` : undefined, spaceSlug: space, moduleId: 'rtime', @@ -285,7 +296,7 @@ routes.post("/api/connections", async (c) => { }).catch(() => {}); } - return c.json(tDoc.connections[id], 201); + return c.json(updatedTDoc.connections[id], 201); }); routes.delete("/api/connections/:id", async (c) => { @@ -328,6 +339,41 @@ routes.delete("/api/connections/:id", async (c) => { return c.json({ ok: true }); }); +routes.patch("/api/connections/:id", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims; + try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const id = c.req.param("id"); + const body = await c.req.json(); + const { status } = body; + if (status !== 'committed' && status !== 'declined') return c.json({ error: "status must be 'committed' or 'declined'" }, 400); + + ensureTasksDoc(space); + ensureCommitmentsDoc(space); + + const doc = _syncServer!.getDoc(tasksDocId(space))!; + const connection = doc.connections[id]; + if (!connection) return c.json({ error: "Not found" }, 404); + + if (status === 'declined') { + _syncServer!.changeDoc(tasksDocId(space), 'decline connection', (d) => { + delete d.connections[id]; + }); + return c.json({ ok: true, deleted: true }); + } + + // status === 'committed' + _syncServer!.changeDoc(tasksDocId(space), 'approve connection', (d) => { + d.connections[id].status = 'committed' as any; + }); + + const updated = _syncServer!.getDoc(tasksDocId(space))!; + return c.json(updated.connections[id]); +}); + // ── Exec State API ── routes.put("/api/tasks/:id/exec-state", async (c) => { diff --git a/modules/rtime/schemas.ts b/modules/rtime/schemas.ts index 30b756d..3b218a1 100644 --- a/modules/rtime/schemas.ts +++ b/modules/rtime/schemas.ts @@ -60,6 +60,8 @@ export interface Connection { fromCommitmentId: string; toTaskId: string; skill: string; + hours: number; // hours allocated in this connection + status: 'proposed' | 'committed'; // approval state } export interface ExecState {