feat(rtime): commitment splitting, pool removal & approval workflow
When dragging a commitment to a task, hours are now split (min of available, needed), the pool orb shrinks or disappears, and connections track proposed/committed status with approve/decline UI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4a54e6af16
commit
801de38b4a
|
|
@ -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<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 _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<any> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<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) => {
|
||||
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<CommitmentsDoc>(commitmentsDocId(space));
|
||||
const tDoc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
||||
const commitment = cDoc?.items?.[fromCommitmentId];
|
||||
const task = tDoc.tasks?.[toTaskId];
|
||||
const updatedTDoc = _syncServer!.getDoc<TasksDoc>(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<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 ──
|
||||
|
||||
routes.put("/api/tasks/:id/exec-state", async (c) => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue