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

This commit is contained in:
Jeff Emmett 2026-04-01 14:38:51 -07:00
commit 8f9aa98995
8 changed files with 1040 additions and 7 deletions

View File

@ -408,6 +408,54 @@ registry.push(
},
);
// ── rTime Commitment/Task Tools ──
registry.push(
{
declaration: {
name: "create_commitment_pool",
description: "Create a commitment pool basket on the canvas. Shows floating orbs representing community time pledges that can be dragged onto task cards.",
parameters: {
type: "object",
properties: {
spaceSlug: { type: "string", description: "The space slug to load commitments from" },
},
required: ["spaceSlug"],
},
},
tagName: "folk-commitment-pool",
buildProps: (args) => ({
spaceSlug: args.spaceSlug || "demo",
}),
actionLabel: (args) => `Created commitment pool for ${args.spaceSlug || "demo"}`,
},
{
declaration: {
name: "create_task_request",
description: "Create a task request card on the canvas with skill slots. Commitments can be dragged from the pool onto matching skill slots.",
parameters: {
type: "object",
properties: {
taskName: { type: "string", description: "Name of the task" },
spaceSlug: { type: "string", description: "The space slug this task belongs to" },
needsJson: { type: "string", description: 'JSON object of skill needs, e.g. {"facilitation":3,"design":2}' },
},
required: ["taskName"],
},
},
tagName: "folk-task-request",
buildProps: (args) => {
let needs: Record<string, number> = {};
try { needs = JSON.parse(args.needsJson || "{}"); } catch { needs = {}; }
return {
taskName: args.taskName,
spaceSlug: args.spaceSlug || "demo",
needs,
};
},
actionLabel: (args) => `Created task request: ${args.taskName}`,
},
);
// ── Design Agent Tool ──
registry.push({
declaration: {

488
lib/folk-commitment-pool.ts Normal file
View File

@ -0,0 +1,488 @@
/**
* folk-commitment-pool Canvas shape rendering a physics-based basket of
* commitment orbs. Orbs can be dragged out onto folk-task-request shapes.
*
* Extracted from modules/rtime/components/folk-timebank-app.ts (pool view).
*/
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
// ── Skill constants (mirrored from rtime/schemas to avoid server import) ──
const SKILL_COLORS: Record<string, string> = {
facilitation: '#8b5cf6', design: '#ec4899', tech: '#3b82f6',
outreach: '#10b981', logistics: '#f59e0b',
};
const SKILL_LABELS: Record<string, string> = {
facilitation: 'Facilitation', design: 'Design', tech: 'Tech',
outreach: 'Outreach', logistics: 'Logistics',
};
// ── Types ──
interface PoolCommitment {
id: string;
memberName: string;
hours: number;
skill: string;
desc: string;
ownerDid?: string;
}
// ── Orb class ──
class Orb {
c: PoolCommitment;
baseRadius: number;
radius: number;
x: number;
y: number;
vx: number;
vy: number;
hoverT = 0;
phase: number;
opacity = 0;
color: string;
constructor(c: PoolCommitment, cx: number, cy: number, r: number) {
this.c = c;
this.baseRadius = 18 + c.hours * 9;
this.radius = this.baseRadius;
const a = Math.random() * Math.PI * 2;
const d = Math.random() * (r - this.baseRadius - 10);
this.x = cx + Math.cos(a) * d;
this.y = cy + Math.sin(a) * d;
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(hovered: Orb | null, cx: number, cy: number, r: 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 - cx, dy = this.y - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
const maxDist = r - this.radius - 3;
if (dist > maxDist && dist > 0.1) {
const nx = dx / dist, ny = dy / dist;
this.x = cx + nx * maxDist;
this.y = cy + 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 = hovered === 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();
}
}
// ── Styles ──
const styles = css`
:host {
background: transparent;
border-radius: 12px;
overflow: hidden;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
.empty-msg {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 13px;
font-style: italic;
pointer-events: none;
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-commitment-pool": FolkCommitmentPool;
}
}
export class FolkCommitmentPool extends FolkShape {
static override tagName = "folk-commitment-pool";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n");
const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#spaceSlug = "demo";
#canvas!: HTMLCanvasElement;
#ctx!: CanvasRenderingContext2D;
#orbs: Orb[] = [];
#ripples: Ripple[] = [];
#animFrame = 0;
#hoveredOrb: Orb | null = null;
#draggingOrb: Orb | null = null;
#ghost: HTMLDivElement | null = null;
#dpr = 1;
#wrapper!: HTMLElement;
get spaceSlug() { return this.#spaceSlug; }
set spaceSlug(v: string) {
this.#spaceSlug = v;
this.#fetchCommitments();
}
override createRenderRoot() {
const root = super.createRenderRoot();
this.#wrapper = document.createElement("div");
this.#wrapper.style.cssText = "width:100%;height:100%;position:relative;";
this.#wrapper.innerHTML = html`<canvas></canvas><div class="empty-msg" style="display:none">No commitments yet</div>`;
const slot = root.querySelector("slot");
const container = slot?.parentElement as HTMLElement;
if (container) container.replaceWith(this.#wrapper);
this.#canvas = this.#wrapper.querySelector("canvas")!;
this.#ctx = this.#canvas.getContext("2d")!;
this.#canvas.addEventListener("pointermove", this.#onPointerMove);
this.#canvas.addEventListener("pointerdown", this.#onPointerDown);
this.#canvas.addEventListener("pointerleave", () => { this.#hoveredOrb = null; });
this.#fetchCommitments();
this.#startAnimation();
return root;
}
disconnectedCallback() {
if (this.#animFrame) cancelAnimationFrame(this.#animFrame);
this.#animFrame = 0;
this.#removeGhost();
}
// ── Data fetching ──
async #fetchCommitments() {
try {
const resp = await fetch(`/${this.#spaceSlug}/rtime/api/commitments`);
if (!resp.ok) return;
const data = await resp.json();
const commitments: PoolCommitment[] = data.commitments || [];
this.#rebuildOrbs(commitments);
} catch { /* network error, keep existing orbs */ }
}
#rebuildOrbs(commitments: PoolCommitment[]) {
const w = this.width || 500;
const h = this.height || 500;
const cx = w / 2, cy = h / 2;
const r = Math.min(cx, cy) * 0.85;
const emptyMsg = this.#wrapper.querySelector(".empty-msg") as HTMLElement;
if (emptyMsg) emptyMsg.style.display = commitments.length === 0 ? "flex" : "none";
// Preserve existing orbs by commitment ID
const existing = new Map(this.#orbs.map(o => [o.c.id, o]));
this.#orbs = commitments.map(c => existing.get(c.id) || new Orb(c, cx, cy, r));
}
// ── Canvas coord helpers ──
#canvasCoords(e: PointerEvent): { x: number; y: number } {
const rect = this.#canvas.getBoundingClientRect();
return {
x: (e.clientX - rect.left) * (this.#canvas.width / this.#dpr / rect.width),
y: (e.clientY - rect.top) * (this.#canvas.height / this.#dpr / rect.height),
};
}
#findOrbAt(x: number, y: number): Orb | null {
for (let i = this.#orbs.length - 1; i >= 0; i--) {
if (this.#orbs[i].contains(x, y)) return this.#orbs[i];
}
return null;
}
// ── Pointer handlers ──
#onPointerMove = (e: PointerEvent) => {
if (this.#draggingOrb) return; // drag is handled on document
const { x, y } = this.#canvasCoords(e);
const orb = this.#findOrbAt(x, y);
this.#hoveredOrb = orb;
this.#canvas.style.cursor = orb ? "grab" : "default";
};
#onPointerDown = (e: PointerEvent) => {
const { x, y } = this.#canvasCoords(e);
const orb = this.#findOrbAt(x, y);
if (!orb) return;
// Prevent FolkShape from starting a shape-move
e.stopPropagation();
this.#draggingOrb = orb;
this.#ripples.push(new Ripple(orb.x, orb.y, orb.color));
// Create ghost element on document.body
this.#ghost = document.createElement("div");
this.#ghost.style.cssText = `
position: fixed; z-index: 99999; pointer-events: none;
width: ${orb.radius * 2}px; height: ${orb.radius * 2}px;
border-radius: 50%;
background: ${orb.color};
opacity: 0.8;
box-shadow: 0 0 20px ${orb.color}80;
display: flex; align-items: center; justify-content: center;
color: white; font-size: 11px; font-weight: 600;
font-family: -apple-system, sans-serif;
transform: translate(-50%, -50%);
left: ${e.clientX}px; top: ${e.clientY}px;
`;
this.#ghost.textContent = `${orb.c.hours}hr ${SKILL_LABELS[orb.c.skill] || orb.c.skill}`;
document.body.appendChild(this.#ghost);
// Dispatch drag start
document.dispatchEvent(new CustomEvent("commitment-drag-start", {
detail: {
commitmentId: orb.c.id,
skill: orb.c.skill,
hours: orb.c.hours,
memberName: orb.c.memberName,
ownerDid: orb.c.ownerDid,
},
}));
document.addEventListener("pointermove", this.#onDocPointerMove);
document.addEventListener("pointerup", this.#onDocPointerUp);
};
#onDocPointerMove = (e: PointerEvent) => {
if (this.#ghost) {
this.#ghost.style.left = `${e.clientX}px`;
this.#ghost.style.top = `${e.clientY}px`;
}
document.dispatchEvent(new CustomEvent("commitment-drag-move", {
detail: { clientX: e.clientX, clientY: e.clientY },
}));
};
#onDocPointerUp = (e: PointerEvent) => {
document.removeEventListener("pointermove", this.#onDocPointerMove);
document.removeEventListener("pointerup", this.#onDocPointerUp);
document.dispatchEvent(new CustomEvent("commitment-drag-end", {
detail: {
clientX: e.clientX,
clientY: e.clientY,
commitmentId: this.#draggingOrb?.c.id,
skill: this.#draggingOrb?.c.skill,
hours: this.#draggingOrb?.c.hours,
memberName: this.#draggingOrb?.c.memberName,
ownerDid: this.#draggingOrb?.c.ownerDid,
},
}));
this.#draggingOrb = null;
this.#removeGhost();
};
#removeGhost() {
if (this.#ghost) {
this.#ghost.remove();
this.#ghost = null;
}
}
// ── Animation loop ──
#startAnimation() {
const frame = () => {
this.#animFrame = requestAnimationFrame(frame);
this.#poolFrame();
};
this.#animFrame = requestAnimationFrame(frame);
}
#poolFrame() {
const w = this.width || 500;
const h = this.height || 500;
const cx = w / 2, cy = h / 2;
const r = Math.min(cx, cy) * 0.85;
// Resize canvas if needed
this.#dpr = window.devicePixelRatio || 1;
const cw = Math.round(w * this.#dpr);
const ch = Math.round(h * this.#dpr);
if (this.#canvas.width !== cw || this.#canvas.height !== ch) {
this.#canvas.width = cw;
this.#canvas.height = ch;
}
const ctx = this.#ctx;
ctx.setTransform(this.#dpr, 0, 0, this.#dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// Draw basket
ctx.save();
ctx.strokeStyle = '#334155';
ctx.lineWidth = 2;
ctx.setLineDash([8, 4]);
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
// Basket label
ctx.fillStyle = '#475569';
ctx.font = '500 12px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Commitment Pool', cx, cy - r + 20);
ctx.fillStyle = '#64748b';
ctx.font = '400 11px -apple-system, sans-serif';
ctx.fillText(`${this.#orbs.length} pledges`, cx, cy - r + 36);
ctx.restore();
// Update & draw ripples
this.#ripples = this.#ripples.filter(rp => { rp.update(); rp.draw(ctx); return rp.o > 0; });
// Orb-to-orb collision
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 minD = a.radius + b.radius + 2;
if (dist < minD && dist > 0.01) {
const nx = dx / dist, ny = dy / dist;
const overlap = (minD - dist) / 2;
a.x -= nx * overlap; a.y -= ny * overlap;
b.x += nx * overlap; b.y += ny * overlap;
const relV = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
if (relV > 0) {
a.vx -= relV * nx * 0.5; a.vy -= relV * ny * 0.5;
b.vx += relV * nx * 0.5; b.vy += relV * ny * 0.5;
}
}
}
}
// Update & draw orbs
for (const orb of this.#orbs) {
if (orb === this.#draggingOrb) {
orb.opacity = 0.3;
orb.draw(ctx);
continue;
}
orb.update(this.#hoveredOrb, cx, cy, r);
orb.draw(ctx);
}
}
// ── Serialization ──
override toJSON() {
return {
...super.toJSON(),
type: "folk-commitment-pool",
spaceSlug: this.#spaceSlug,
};
}
static override fromData(data: Record<string, any>): FolkCommitmentPool {
const shape = FolkShape.fromData(data) as FolkCommitmentPool;
if (data.spaceSlug) shape.spaceSlug = data.spaceSlug;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.spaceSlug && data.spaceSlug !== this.#spaceSlug) this.spaceSlug = data.spaceSlug;
}
}

439
lib/folk-task-request.ts Normal file
View File

@ -0,0 +1,439 @@
/**
* folk-task-request Canvas shape representing a task card with skill slots.
* Acts as a drop target for orbs dragged from folk-commitment-pool.
*
* On drop: POST connection to rTime API + dispatch notification event.
*/
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
// Skill constants (mirrored from rtime/schemas)
const SKILL_COLORS: Record<string, string> = {
facilitation: '#8b5cf6', design: '#ec4899', tech: '#3b82f6',
outreach: '#10b981', logistics: '#f59e0b',
};
const SKILL_LABELS: Record<string, string> = {
facilitation: 'Facilitation', design: 'Design', tech: 'Tech',
outreach: 'Outreach', logistics: 'Logistics',
};
const styles = css`
:host {
background: #1e293b;
border-radius: 10px;
border: 1.5px solid #334155;
overflow: hidden;
}
:host(.drag-highlight) {
border-color: #14b8a6;
box-shadow: 0 0 16px rgba(20, 184, 166, 0.3);
}
.header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px 6px;
cursor: move;
}
.header .icon { font-size: 16px; }
.task-name {
flex: 1;
font-size: 13px;
font-weight: 600;
color: #e2e8f0;
background: none;
border: none;
outline: none;
font-family: inherit;
padding: 0;
}
.task-name:focus {
border-bottom: 1px solid #14b8a6;
}
.task-name::placeholder { color: #64748b; }
.desc {
padding: 0 12px 8px;
font-size: 11px;
color: #94a3b8;
line-height: 1.4;
}
.desc-input {
width: 100%;
background: none;
border: none;
outline: none;
color: #94a3b8;
font-size: 11px;
font-family: inherit;
padding: 0;
resize: none;
}
.desc-input::placeholder { color: #475569; }
.skill-slots {
padding: 0 12px 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.slot {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
border-radius: 6px;
background: #0f172a;
border: 1.5px dashed #334155;
font-size: 11px;
color: #cbd5e1;
transition: border-color 0.2s, background 0.2s;
}
.slot.highlight {
border-color: #14b8a6;
background: rgba(20, 184, 166, 0.08);
}
.slot.filled {
border-style: solid;
opacity: 0.7;
}
.slot-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.slot-label { flex: 1; }
.slot-hours {
font-weight: 600;
font-size: 10px;
color: #64748b;
}
.slot-assigned {
font-size: 10px;
color: #14b8a6;
font-weight: 500;
}
`;
interface SlotState {
skill: string;
hoursNeeded: number;
assignedMember?: string;
commitmentId?: string;
}
declare global {
interface HTMLElementTagNameMap {
"folk-task-request": FolkTaskRequest;
}
}
export class FolkTaskRequest extends FolkShape {
static override tagName = "folk-task-request";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n");
const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#taskId = "";
#spaceSlug = "demo";
#taskName = "New Task";
#taskDescription = "";
#needs: Record<string, number> = {};
#slots: SlotState[] = [];
#wrapper!: HTMLElement;
#slotsContainer!: HTMLElement;
#nameInput!: HTMLInputElement;
#descInput!: HTMLTextAreaElement;
get taskId() { return this.#taskId; }
set taskId(v: string) { this.#taskId = v; }
get spaceSlug() { return this.#spaceSlug; }
set spaceSlug(v: string) { this.#spaceSlug = v; }
get taskName() { return this.#taskName; }
set taskName(v: string) {
this.#taskName = v;
if (this.#nameInput) this.#nameInput.value = v;
this.dispatchEvent(new CustomEvent("content-change"));
}
get taskDescription() { return this.#taskDescription; }
set taskDescription(v: string) {
this.#taskDescription = v;
if (this.#descInput) this.#descInput.value = v;
this.dispatchEvent(new CustomEvent("content-change"));
}
get needs() { return this.#needs; }
set needs(v: Record<string, number>) {
this.#needs = v;
this.#buildSlots();
this.dispatchEvent(new CustomEvent("content-change"));
}
override createRenderRoot() {
const root = super.createRenderRoot();
this.#wrapper = document.createElement("div");
this.#wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;";
this.#wrapper.innerHTML = html`
<div class="header">
<span class="icon">📋</span>
<input class="task-name" type="text" placeholder="Task name..." />
</div>
<div class="desc">
<textarea class="desc-input" rows="2" placeholder="Description..."></textarea>
</div>
<div class="skill-slots"></div>
`;
const slot = root.querySelector("slot");
const container = slot?.parentElement as HTMLElement;
if (container) container.replaceWith(this.#wrapper);
this.#nameInput = this.#wrapper.querySelector(".task-name") as HTMLInputElement;
this.#descInput = this.#wrapper.querySelector(".desc-input") as HTMLTextAreaElement;
this.#slotsContainer = this.#wrapper.querySelector(".skill-slots") as HTMLElement;
this.#nameInput.value = this.#taskName;
this.#descInput.value = this.#taskDescription;
// Stop events from triggering shape move
const stopProp = (e: Event) => e.stopPropagation();
this.#nameInput.addEventListener("click", stopProp);
this.#descInput.addEventListener("click", stopProp);
this.#nameInput.addEventListener("pointerdown", stopProp);
this.#descInput.addEventListener("pointerdown", stopProp);
this.#nameInput.addEventListener("change", () => {
this.#taskName = this.#nameInput.value.trim() || "New Task";
this.dispatchEvent(new CustomEvent("content-change"));
});
this.#nameInput.addEventListener("keydown", (e) => {
e.stopPropagation();
if (e.key === "Enter") this.#nameInput.blur();
});
this.#descInput.addEventListener("change", () => {
this.#taskDescription = this.#descInput.value.trim();
this.dispatchEvent(new CustomEvent("content-change"));
});
this.#descInput.addEventListener("keydown", (e) => {
e.stopPropagation();
});
this.#buildSlots();
// Listen for commitment drag events
document.addEventListener("commitment-drag-start", this.#onDragStart as EventListener);
document.addEventListener("commitment-drag-move", this.#onDragMove as EventListener);
document.addEventListener("commitment-drag-end", this.#onDragEnd as EventListener);
return root;
}
disconnectedCallback() {
document.removeEventListener("commitment-drag-start", this.#onDragStart as EventListener);
document.removeEventListener("commitment-drag-move", this.#onDragMove as EventListener);
document.removeEventListener("commitment-drag-end", this.#onDragEnd as EventListener);
}
#buildSlots() {
if (!this.#slotsContainer) return;
// Build slots from needs
this.#slots = Object.entries(this.#needs).map(([skill, hours]) => {
// Preserve existing assignment if any
const existing = this.#slots.find(s => s.skill === skill);
return {
skill,
hoursNeeded: hours,
assignedMember: existing?.assignedMember,
commitmentId: existing?.commitmentId,
};
});
this.#renderSlots();
}
#renderSlots() {
if (!this.#slotsContainer) return;
this.#slotsContainer.innerHTML = "";
for (const slot of this.#slots) {
const el = document.createElement("div");
el.className = "slot" + (slot.assignedMember ? " filled" : "");
el.dataset.skill = slot.skill;
el.innerHTML = `
<span class="slot-dot" style="background:${SKILL_COLORS[slot.skill] || '#64748b'}"></span>
<span class="slot-label">${SKILL_LABELS[slot.skill] || slot.skill}</span>
<span class="slot-hours">${slot.hoursNeeded}hr</span>
${slot.assignedMember ? `<span class="slot-assigned">${slot.assignedMember}</span>` : ""}
`;
this.#slotsContainer.appendChild(el);
}
}
// ── Drag handling ──
#activeDragSkill: string | null = null;
#onDragStart = (e: CustomEvent) => {
this.#activeDragSkill = e.detail.skill;
// Highlight matching unfilled slots
const matchingSlots = this.#slots.filter(s => s.skill === this.#activeDragSkill && !s.assignedMember);
if (matchingSlots.length > 0) {
this.classList.add("drag-highlight");
this.#highlightSlots(this.#activeDragSkill!);
}
};
#onDragMove = (e: CustomEvent) => {
// Check if pointer is over this shape
const rect = this.getBoundingClientRect();
const over = e.detail.clientX >= rect.left && e.detail.clientX <= rect.right
&& e.detail.clientY >= rect.top && e.detail.clientY <= rect.bottom;
if (over && this.#activeDragSkill) {
this.classList.add("drag-highlight");
this.#highlightSlots(this.#activeDragSkill);
} else {
this.classList.remove("drag-highlight");
this.#clearSlotHighlights();
}
};
#onDragEnd = (e: CustomEvent) => {
this.classList.remove("drag-highlight");
this.#clearSlotHighlights();
if (!this.#activeDragSkill) return;
const skill = this.#activeDragSkill;
this.#activeDragSkill = null;
// Check if drop landed on this shape
const rect = this.getBoundingClientRect();
const over = e.detail.clientX >= rect.left && e.detail.clientX <= rect.right
&& e.detail.clientY >= rect.top && e.detail.clientY <= rect.bottom;
if (!over) return;
// Find matching unfilled slot
const slot = this.#slots.find(s => s.skill === skill && !s.assignedMember);
if (!slot) return;
// Assign
slot.assignedMember = e.detail.memberName;
slot.commitmentId = e.detail.commitmentId;
this.#renderSlots();
// POST connection
this.#postConnection(e.detail.commitmentId, skill);
this.dispatchEvent(new CustomEvent("commitment-assigned", {
bubbles: true,
detail: {
taskId: this.#taskId,
commitmentId: e.detail.commitmentId,
skill,
},
}));
};
#highlightSlots(skill: string) {
const slotEls = this.#slotsContainer.querySelectorAll(".slot");
slotEls.forEach(el => {
const s = (el as HTMLElement).dataset.skill;
if (s === skill && !el.classList.contains("filled")) {
el.classList.add("highlight");
}
});
}
#clearSlotHighlights() {
this.#slotsContainer.querySelectorAll(".slot.highlight").forEach(el => el.classList.remove("highlight"));
}
async #postConnection(commitmentId: string, skill: string) {
if (!this.#taskId) return;
try {
// Get auth token from cookie or localStorage
const token = document.cookie.split(";").map(c => c.trim()).find(c => c.startsWith("auth_token="))?.split("=")[1]
|| localStorage.getItem("auth_token") || "";
await fetch(`/${this.#spaceSlug}/rtime/api/connections`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { "Authorization": `Bearer ${token}` } : {}),
},
body: JSON.stringify({
fromCommitmentId: commitmentId,
toTaskId: this.#taskId,
skill,
}),
});
} catch { /* best-effort */ }
}
// ── Serialization ──
override toJSON() {
const slotsData: Record<string, { member?: string; commitmentId?: string }> = {};
for (const s of this.#slots) {
if (s.assignedMember) {
slotsData[s.skill] = { member: s.assignedMember, commitmentId: s.commitmentId };
}
}
return {
...super.toJSON(),
type: "folk-task-request",
taskId: this.#taskId,
spaceSlug: this.#spaceSlug,
taskName: this.#taskName,
taskDescription: this.#taskDescription,
needs: this.#needs,
assignments: slotsData,
};
}
static override fromData(data: Record<string, any>): FolkTaskRequest {
const shape = FolkShape.fromData(data) as FolkTaskRequest;
if (data.taskId) shape.taskId = data.taskId;
if (data.spaceSlug) shape.spaceSlug = data.spaceSlug;
if (data.taskName) shape.taskName = data.taskName;
if (data.taskDescription) shape.taskDescription = data.taskDescription;
if (data.needs) shape.needs = data.needs;
// Restore assignments
if (data.assignments) {
for (const [skill, info] of Object.entries(data.assignments as Record<string, any>)) {
const slot = shape.#slots.find(s => s.skill === skill);
if (slot && info.member) {
slot.assignedMember = info.member;
slot.commitmentId = info.commitmentId;
}
}
shape.#renderSlots();
}
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.taskId && data.taskId !== this.#taskId) this.taskId = data.taskId;
if (data.spaceSlug && data.spaceSlug !== this.#spaceSlug) this.spaceSlug = data.spaceSlug;
if (data.taskName !== undefined && data.taskName !== this.#taskName) this.taskName = data.taskName;
if (data.taskDescription !== undefined && data.taskDescription !== this.#taskDescription) this.taskDescription = data.taskDescription;
if (data.needs && JSON.stringify(data.needs) !== JSON.stringify(this.#needs)) this.needs = data.needs;
if (data.assignments) {
for (const [skill, info] of Object.entries(data.assignments as Record<string, any>)) {
const slot = this.#slots.find(s => s.skill === skill);
if (slot && info.member) {
slot.assignedMember = info.member;
slot.commitmentId = info.commitmentId;
}
}
this.#renderSlots();
}
}
}

View File

@ -68,6 +68,10 @@ export * from "./folk-booking";
export * from "./folk-token-mint";
export * from "./folk-token-ledger";
// rTime Canvas Shapes
export * from "./folk-commitment-pool";
export * from "./folk-task-request";
// Transaction Builder
export * from "./folk-transaction-builder";

View File

@ -18,6 +18,7 @@ import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyToken, extractToken } from "../../server/auth";
import { renderLanding } from "./landing";
import { notify } from '../../server/notification-service';
import type { SyncServer } from '../../server/local-first/sync-server';
import {
commitmentsSchema, tasksSchema,
@ -157,7 +158,7 @@ routes.post("/api/commitments", async (c) => {
ensureCommitmentsDoc(space);
_syncServer!.changeDoc<CommitmentsDoc>(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;
d.items[id] = { id, memberName, hours: Math.max(1, Math.min(10, hours)), skill, desc: desc || '', createdAt: now, ownerDid: (claims.did as string) || '' } as any;
});
const doc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space))!;
@ -248,7 +249,8 @@ routes.put("/api/tasks/:id", async (c) => {
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); }
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();
@ -257,13 +259,32 @@ routes.post("/api/connections", async (c) => {
const id = newId();
ensureTasksDoc(space);
ensureCommitmentsDoc(space);
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'add connection', (d) => {
d.connections[id] = { id, fromCommitmentId, toTaskId, skill } as any;
});
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
return c.json(doc.connections[id], 201);
// 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];
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`,
body: task ? `Task: ${task.name}` : undefined,
spaceSlug: space,
moduleId: 'rtime',
actionUrl: `/${space}/rtime`,
actorDid: claims.did as string | undefined,
}).catch(() => {});
}
return c.json(tDoc.connections[id], 201);
});
routes.delete("/api/connections/:id", async (c) => {

View File

@ -40,6 +40,7 @@ export interface Commitment {
createdAt: number;
intentId?: string; // links commitment to its intent
status?: 'active' | 'matched' | 'settled' | 'withdrawn';
ownerDid?: string; // DID of the commitment creator (for notifications)
}
// ── Task / Connection / ExecState ──

View File

@ -52,7 +52,9 @@ export type NotificationEventType =
// Social
| 'mention' | 'ping_user'
// Delegation
| 'delegation_received' | 'delegation_revoked' | 'delegation_expired';
| 'delegation_received' | 'delegation_revoked' | 'delegation_expired'
// Commitment (rTime)
| 'commitment_requested' | 'commitment_accepted' | 'commitment_declined';
export interface NotifyOptions {
userDid: string;

View File

@ -1599,7 +1599,8 @@
folk-booking, folk-token-mint, folk-token-ledger,
folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction,
folk-social-post, folk-splat, folk-blender, folk-drawfast,
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-holon, folk-holon-browser, folk-multisig-email) {
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-holon, folk-holon-browser, folk-multisig-email,
folk-commitment-pool, folk-task-request) {
cursor: crosshair;
}
@ -1611,7 +1612,8 @@
folk-booking, folk-token-mint, folk-token-ledger,
folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction,
folk-social-post, folk-splat, folk-blender, folk-drawfast,
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-holon, folk-holon-browser, folk-multisig-email):hover {
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-holon, folk-holon-browser, folk-multisig-email,
folk-commitment-pool, folk-task-request):hover {
outline: 2px dashed #3b82f6;
outline-offset: 4px;
}
@ -2095,6 +2097,8 @@
<button id="new-spider-3d" title="3D Spider">📊 3D Spider</button>
<button id="new-conviction" title="Conviction">⏳ Conviction</button>
<button id="new-token" title="Token">🪙 Token</button>
<button id="new-commitment-pool" title="Commitment Pool" data-requires-module="rtime">🧺 Commitments</button>
<button id="new-task-request" title="Task Request" data-requires-module="rtime">📋 Task Request</button>
<button id="new-multisig-email" title="Multi-Sig Email">✉️ Multi-Sig Email</button>
<button id="embed-vote" title="Embed rVote" data-requires-module="rvote">🗳️ rVote</button>
</div>
@ -2453,6 +2457,8 @@
FolkBooking,
FolkTokenMint,
FolkTokenLedger,
FolkCommitmentPool,
FolkTaskRequest,
FolkTransactionBuilder,
FolkChoiceVote,
FolkChoiceRank,
@ -2705,6 +2711,8 @@
FolkBooking.define();
FolkTokenMint.define();
FolkTokenLedger.define();
FolkCommitmentPool.define();
FolkTaskRequest.define();
FolkTransactionBuilder.define();
FolkChoiceVote.define();
FolkChoiceRank.define();
@ -2758,6 +2766,8 @@
shapeRegistry.register("folk-booking", FolkBooking);
shapeRegistry.register("folk-token-mint", FolkTokenMint);
shapeRegistry.register("folk-token-ledger", FolkTokenLedger);
shapeRegistry.register("folk-commitment-pool", FolkCommitmentPool);
shapeRegistry.register("folk-task-request", FolkTaskRequest);
shapeRegistry.register("folk-transaction-builder", FolkTransactionBuilder);
shapeRegistry.register("folk-choice-vote", FolkChoiceVote);
shapeRegistry.register("folk-choice-rank", FolkChoiceRank);
@ -3137,6 +3147,7 @@
"folk-workflow-block", "folk-itinerary", "folk-destination",
"folk-budget", "folk-packing-list", "folk-booking",
"folk-token-mint", "folk-token-ledger",
"folk-commitment-pool", "folk-task-request",
"folk-choice-vote", "folk-choice-rank", "folk-choice-spider",
"folk-spider-3d", "folk-choice-conviction", "folk-social-post",
"folk-splat", "folk-blender", "folk-drawfast",
@ -3946,6 +3957,8 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
"folk-booking": { width: 300, height: 240 },
"folk-token-mint": { width: 320, height: 280 },
"folk-token-ledger": { width: 380, height: 400 },
"folk-commitment-pool": { width: 500, height: 500 },
"folk-task-request": { width: 300, height: 280 },
"folk-choice-vote": { width: 360, height: 400 },
"folk-choice-rank": { width: 380, height: 480 },
"folk-choice-spider": { width: 440, height: 540 },
@ -4630,6 +4643,22 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
});
});
// Commitment Pool creation
document.getElementById("new-commitment-pool").addEventListener("click", () => {
setPendingTool("folk-commitment-pool", {
spaceSlug: communitySlug,
});
});
// Task Request creation
document.getElementById("new-task-request").addEventListener("click", () => {
setPendingTool("folk-task-request", {
spaceSlug: communitySlug,
taskName: "New Task",
needs: { facilitation: 2, design: 2, tech: 2 },
});
});
// Decision/choice components
document.getElementById("new-choice-vote").addEventListener("click", () => {
setPendingTool("folk-choice-vote", {
@ -6096,6 +6125,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
"folk-workflow-block": "⚙️", "folk-itinerary": "🗓️", "folk-destination": "📍",
"folk-budget": "💰", "folk-packing-list": "🎒", "folk-booking": "✈️",
"folk-token-mint": "🪙", "folk-token-ledger": "📒",
"folk-commitment-pool": "🧺", "folk-task-request": "📋",
"folk-choice-vote": "☑", "folk-choice-rank": "📊",
"folk-choice-spider": "🕸", "folk-spider-3d": "📊", "folk-choice-conviction": "⏳", "folk-social-post": "📱",
"folk-splat": "🔮", "folk-blender": "🧊", "folk-drawfast": "✏️",