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;
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 {

View File

@ -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) => {

View File

@ -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 {