Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m34s Details

This commit is contained in:
Jeff Emmett 2026-04-03 14:20:59 -07:00
commit 4ecdbc0ef0
3 changed files with 273 additions and 48 deletions

View File

@ -63,6 +63,9 @@ interface Wire {
from: string; from: string;
to: string; to: string;
skill: string; skill: string;
hours: number;
status: 'proposed' | 'committed';
connectionId?: string;
} }
// ── Orb class ── // ── Orb class ──
@ -309,7 +312,7 @@ class FolkTimebankApp extends HTMLElement {
// Exec state // Exec state
private execStepStates: Record<string, Record<number, string>> = {}; private execStepStates: Record<string, Record<number, string>> = {};
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 _currentExecTaskId: string | null = null;
private _cyclosMembers: { id: string; name: string; balance: number }[] = []; private _cyclosMembers: { id: string; name: string; balance: number }[] = [];
private _theme: 'dark' | 'light' = 'dark'; private _theme: 'dark' | 'light' = 'dark';
@ -384,11 +387,14 @@ class FolkTimebankApp extends HTMLElement {
if (tResp.ok) { if (tResp.ok) {
const tData = await tResp.json(); const tData = await tResp.json();
this.tasks = tData.tasks || []; this.tasks = tData.tasks || [];
// Restore connections // Restore connections (with hours + status)
this._restoredConnections = (tData.connections || []).map((cn: any) => ({ this._restoredConnections = (tData.connections || []).map((cn: any) => ({
id: cn.id,
fromCommitmentId: cn.fromCommitmentId, fromCommitmentId: cn.fromCommitmentId,
toTaskId: cn.toTaskId, toTaskId: cn.toTaskId,
skill: cn.skill, skill: cn.skill,
hours: cn.hours || 0,
status: cn.status || 'proposed',
})); }));
// Restore exec states // Restore exec states
for (const es of (tData.execStates || [])) { for (const es of (tData.execStates || [])) {
@ -753,9 +759,28 @@ class FolkTimebankApp extends HTMLElement {
this.basketR = Math.min(this.poolW, this.poolH) * 0.42; 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() { private buildOrbs() {
this.orbs = this.commitments.map(c => { this.orbs = this.commitments
const orb = new Orb(c, this.basketCX, this.basketCY, this.basketR); .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; orb.statusRing = this.memberIntentStatus.get(c.memberName) || null;
return orb; return orb;
}); });
@ -1174,10 +1199,26 @@ class FolkTimebankApp extends HTMLElement {
const idx = skills.indexOf(conn.skill); const idx = skills.indexOf(conn.skill);
if (idx >= 0) y2 = to.y + TASK_H_BASE + idx * TASK_ROW + TASK_ROW / 2; if (idx >= 0) y2 = to.y + TASK_H_BASE + idx * TASK_ROW + TASK_ROW / 2;
} }
const isCommitted = conn.status === 'committed';
const p = ns('path'); const p = ns('path');
p.setAttribute('d', bezier(x1, y1, x2, y2)); p.setAttribute('d', bezier(x1, y1, x2, y2));
p.setAttribute('class', 'connection-line active'); 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); 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'; port.style.pointerEvents = 'none';
g.appendChild(port); 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') { } else if (node.type === 'task') {
const t = node.data as TaskData; const t = node.data as TaskData;
const skills = Object.keys(t.needs); const skills = Object.keys(t.needs);
@ -1294,7 +1369,11 @@ class FolkTimebankApp extends HTMLElement {
skills.forEach((skill, i) => { skills.forEach((skill, i) => {
const ry = TASK_H_BASE + i * TASK_ROW; const ry = TASK_H_BASE + i * TASK_ROW;
const needed = t.needs[skill]; 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 done = ful >= needed;
const portHit = ns('circle'); const portHit = ns('circle');
@ -1303,10 +1382,13 @@ class FolkTimebankApp extends HTMLElement {
portHit.setAttribute('class', 'port'); portHit.setAttribute('class', 'port');
portHit.setAttribute('data-node', node.id); portHit.setAttribute('data-port', 'input'); portHit.setAttribute('data-skill', skill); portHit.setAttribute('data-node', node.id); portHit.setAttribute('data-port', 'input'); portHit.setAttribute('data-skill', skill);
g.appendChild(portHit); 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'); const port = ns('circle');
port.setAttribute('cx', '0'); port.setAttribute('cy', String(ry + TASK_ROW / 2)); port.setAttribute('cx', '0'); port.setAttribute('cy', String(ry + TASK_ROW / 2));
port.setAttribute('r', String(PORT_R)); 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.setAttribute('stroke', '#fff'); port.setAttribute('stroke-width', '2');
port.style.pointerEvents = 'none'; port.style.pointerEvents = 'none';
g.appendChild(port); g.appendChild(port);
@ -1316,9 +1398,20 @@ class FolkTimebankApp extends HTMLElement {
dot.setAttribute('r', '4'); dot.setAttribute('fill', SKILL_COLORS[skill] || '#888'); dot.setAttribute('r', '4'); dot.setAttribute('fill', SKILL_COLORS[skill] || '#888');
g.appendChild(dot); g.appendChild(dot);
const lbl = (SKILL_LABELS[skill] || skill) + ': ' + ful + '/' + needed + 'hr'; // Build label showing committed/proposed/needed
g.appendChild(svgText(lbl, 30, ry + TASK_ROW / 2 + 4, 11, done ? '#10b981' : '#94a3b8', done ? '600' : '400')); let lbl: string;
if (done) g.appendChild(svgText('\u2713', node.w - 16, ry + TASK_ROW / 2 + 4, 13, '#10b981', '600', 'middle')); 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) { if (ready) {
@ -1368,6 +1461,18 @@ class FolkTimebankApp extends HTMLElement {
return; 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; const port = (e.target as Element).closest('.port') as SVGElement;
if (port) { if (port) {
e.preventDefault(); e.preventDefault();
@ -1465,11 +1570,17 @@ class FolkTimebankApp extends HTMLElement {
const fNode = this.weaveNodes.find(n => n.id === fromId); const fNode = this.weaveNodes.find(n => n.id === fromId);
const tNode = this.weaveNodes.find(n => n.id === toId); const tNode = this.weaveNodes.find(n => n.id === toId);
if (fNode && tNode && fNode.type === 'commitment' && tNode.type === 'task' && fNode.data.skill === skill) { if (fNode && tNode && fNode.type === 'commitment' && tNode.type === 'task' && fNode.data.skill === skill) {
if (!this.connections.find(c => c.from === fromId && c.to === toId)) { if (!this.connections.find(c => c.from === fromId && c.to === toId && c.skill === skill)) {
this.connections.push({ from: fromId, to: toId, 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 = {}; if (!tNode.data.fulfilled) tNode.data.fulfilled = {};
tNode.data.fulfilled[skill] = (tNode.data.fulfilled[skill] || 0) + fNode.data.hours; tNode.data.fulfilled[skill] = (tNode.data.fulfilled[skill] || 0) + allocate;
this.persistConnection(fromId, toId, skill); this.persistConnection(fromId, toId, skill, allocate);
this.buildOrbs();
const nowReady = this.isTaskReady(tNode); const nowReady = this.isTaskReady(tNode);
this.renderAll(); this.renderAll();
this.rebuildSidebar(); this.rebuildSidebar();
@ -1478,6 +1589,7 @@ class FolkTimebankApp extends HTMLElement {
} }
} }
} }
}
this.connecting = null; this.connecting = null;
this.tempConn.style.display = 'none'; this.tempConn.style.display = 'none';
this.tempConn.setAttribute('d', ''); this.tempConn.setAttribute('d', '');
@ -1603,28 +1715,41 @@ class FolkTimebankApp extends HTMLElement {
if (wrap && this.draggingOrb) { if (wrap && this.draggingOrb) {
const wr = wrap.getBoundingClientRect(); const wr = wrap.getBoundingClientRect();
if (ev.clientX >= wr.left && ev.clientX <= wr.right && ev.clientY >= wr.top && ev.clientY <= wr.bottom) { 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); 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? // Hit-test: did we drop on an unfulfilled task port?
const dropNode = this.hitTestTaskPort(pt.x, pt.y, c.skill); const dropNode = this.hitTestTaskPort(pt.x, pt.y, c.skill);
if (dropNode) { 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; const fromId = 'cn-' + c.id;
if (!this.connections.find(w => w.from === fromId && w.to === dropNode.id)) { // Ensure commitment node exists on canvas
this.connections.push({ from: fromId, to: dropNode.id, skill: c.skill }); 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 = {}; if (!dropNode.data.fulfilled) dropNode.data.fulfilled = {};
dropNode.data.fulfilled[c.skill] = (dropNode.data.fulfilled[c.skill] || 0) + c.hours; dropNode.data.fulfilled[c.skill] = (dropNode.data.fulfilled[c.skill] || 0) + allocate;
this.persistConnection(fromId, dropNode.id, c.skill); this.persistConnection(fromId, dropNode.id, c.skill, allocate);
}
}
} 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.renderAll();
this.rebuildSidebar(); this.rebuildSidebar();
} }
} }
}
this.cancelOrbDrag(); this.cancelOrbDrag();
}; };
const cancelOrbHandler = () => { const cancelOrbHandler = () => {
@ -1690,25 +1815,75 @@ class FolkTimebankApp extends HTMLElement {
const fNode = this.weaveNodes.find(n => n.id === fromNodeId); const fNode = this.weaveNodes.find(n => n.id === fromNodeId);
const tNode = this.weaveNodes.find(n => n.id === toNodeId); const tNode = this.weaveNodes.find(n => n.id === toNodeId);
if (!fNode || !tNode) continue; if (!fNode || !tNode) continue;
if (this.connections.find(c => c.from === fromNodeId && c.to === toNodeId)) continue; 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 }); 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 = {}; 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._restoredConnections = [];
this.renderAll(); this.renderAll();
this.rebuildSidebar(); this.rebuildSidebar();
} }
private async persistConnection(from: string, to: string, skill: string) { private async persistConnection(from: string, to: string, skill: string, hours: number): Promise<any> {
const fromCommitmentId = from.replace(/^cn-/, ''); const fromCommitmentId = from.replace(/^cn-/, '');
try { try {
await fetch(`${this.getApiBase()}/api/connections`, { const res = await fetch(`${this.getApiBase()}/api/connections`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, 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 */ } } 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) { private async persistExecState(taskId: string) {
@ -2606,6 +2781,8 @@ const CSS_TEXT = `
.exec-btn-rect { cursor: pointer; transition: opacity 0.15s; } .exec-btn-rect { cursor: pointer; transition: opacity 0.15s; }
.exec-btn-rect:hover { opacity: 0.85; } .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 */ /* Modals & overlays */
.modal-overlay, .exec-overlay, .task-edit-overlay { .modal-overlay, .exec-overlay, .task-edit-overlay {

View File

@ -254,28 +254,39 @@ routes.post("/api/connections", async (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";
const body = await c.req.json(); 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 (!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(); const id = newId();
ensureTasksDoc(space); ensureTasksDoc(space);
ensureCommitmentsDoc(space); ensureCommitmentsDoc(space);
// Validate: hours <= commitment's available hours
const cDoc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space));
const commitment = cDoc?.items?.[fromCommitmentId];
if (!commitment) return c.json({ error: "Commitment not found" }, 404);
const tDoc = _syncServer!.getDoc<TasksDoc>(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<TasksDoc>(tasksDocId(space), 'add connection', (d) => { _syncServer!.changeDoc<TasksDoc>(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 // Notify commitment owner that their time was requested
const cDoc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space)); const updatedTDoc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
const tDoc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!; const task = updatedTDoc.tasks?.[toTaskId];
const commitment = cDoc?.items?.[fromCommitmentId];
const task = tDoc.tasks?.[toTaskId];
if (commitment?.ownerDid && commitment.ownerDid !== claims.did) { if (commitment?.ownerDid && commitment.ownerDid !== claims.did) {
notify({ notify({
userDid: commitment.ownerDid, userDid: commitment.ownerDid,
category: 'module', category: 'module',
eventType: 'commitment_requested', 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, body: task ? `Task: ${task.name}` : undefined,
spaceSlug: space, spaceSlug: space,
moduleId: 'rtime', moduleId: 'rtime',
@ -285,7 +296,7 @@ routes.post("/api/connections", async (c) => {
}).catch(() => {}); }).catch(() => {});
} }
return c.json(tDoc.connections[id], 201); return c.json(updatedTDoc.connections[id], 201);
}); });
routes.delete("/api/connections/:id", async (c) => { routes.delete("/api/connections/:id", async (c) => {
@ -328,6 +339,41 @@ routes.delete("/api/connections/:id", async (c) => {
return c.json({ ok: true }); 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<TasksDoc>(tasksDocId(space))!;
const connection = doc.connections[id];
if (!connection) return c.json({ error: "Not found" }, 404);
if (status === 'declined') {
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'decline connection', (d) => {
delete d.connections[id];
});
return c.json({ ok: true, deleted: true });
}
// status === 'committed'
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'approve connection', (d) => {
d.connections[id].status = 'committed' as any;
});
const updated = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
return c.json(updated.connections[id]);
});
// ── Exec State API ── // ── Exec State API ──
routes.put("/api/tasks/:id/exec-state", async (c) => { routes.put("/api/tasks/:id/exec-state", async (c) => {

View File

@ -60,6 +60,8 @@ export interface Connection {
fromCommitmentId: string; fromCommitmentId: string;
toTaskId: string; toTaskId: string;
skill: string; skill: string;
hours: number; // hours allocated in this connection
status: 'proposed' | 'committed'; // approval state
} }
export interface ExecState { export interface ExecState {