From 5b8b6386613c5caf4b6276a398a758d985d5f6cc Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 1 Apr 2026 14:38:41 -0700 Subject: [PATCH] feat: add commitment pool + task request canvas shapes for rTime Two new FolkShape subclasses enable drag-from-pool-to-task interactions on the canvas. Orbs dragged from the commitment pool highlight matching skill slots on task request cards and POST connections via the rTime API. Adds commitment_requested notification type so commitment owners are notified when their time is requested. Co-Authored-By: Claude Opus 4.6 --- lib/canvas-tools.ts | 48 ++++ lib/folk-commitment-pool.ts | 488 +++++++++++++++++++++++++++++++++ lib/folk-task-request.ts | 439 +++++++++++++++++++++++++++++ lib/index.ts | 4 + modules/rtime/mod.ts | 29 +- modules/rtime/schemas.ts | 1 + server/notification-service.ts | 4 +- website/canvas.html | 34 ++- 8 files changed, 1040 insertions(+), 7 deletions(-) create mode 100644 lib/folk-commitment-pool.ts create mode 100644 lib/folk-task-request.ts diff --git a/lib/canvas-tools.ts b/lib/canvas-tools.ts index 7add553..054150c 100644 --- a/lib/canvas-tools.ts +++ b/lib/canvas-tools.ts @@ -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 = {}; + 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: { diff --git a/lib/folk-commitment-pool.ts b/lib/folk-commitment-pool.ts new file mode 100644 index 0000000..3e8810e --- /dev/null +++ b/lib/folk-commitment-pool.ts @@ -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 = { + facilitation: '#8b5cf6', design: '#ec4899', tech: '#3b82f6', + outreach: '#10b981', logistics: '#f59e0b', +}; +const SKILL_LABELS: Record = { + 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``; + + 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): FolkCommitmentPool { + const shape = FolkShape.fromData(data) as FolkCommitmentPool; + if (data.spaceSlug) shape.spaceSlug = data.spaceSlug; + return shape; + } + + override applyData(data: Record): void { + super.applyData(data); + if (data.spaceSlug && data.spaceSlug !== this.#spaceSlug) this.spaceSlug = data.spaceSlug; + } +} diff --git a/lib/folk-task-request.ts b/lib/folk-task-request.ts new file mode 100644 index 0000000..b1bbc9a --- /dev/null +++ b/lib/folk-task-request.ts @@ -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 = { + facilitation: '#8b5cf6', design: '#ec4899', tech: '#3b82f6', + outreach: '#10b981', logistics: '#f59e0b', +}; +const SKILL_LABELS: Record = { + 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 = {}; + #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) { + 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` +
+ 📋 + +
+
+ +
+
+ `; + + 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 = ` + + ${SKILL_LABELS[slot.skill] || slot.skill} + ${slot.hoursNeeded}hr + ${slot.assignedMember ? `${slot.assignedMember}` : ""} + `; + 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 = {}; + 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): 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)) { + 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): 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)) { + const slot = this.#slots.find(s => s.skill === skill); + if (slot && info.member) { + slot.assignedMember = info.member; + slot.commitmentId = info.commitmentId; + } + } + this.#renderSlots(); + } + } +} diff --git a/lib/index.ts b/lib/index.ts index 42aa7d6..2851848 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -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"; diff --git a/modules/rtime/mod.ts b/modules/rtime/mod.ts index e49be90..508c902 100644 --- a/modules/rtime/mod.ts +++ b/modules/rtime/mod.ts @@ -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(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(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(tasksDocId(space), 'add connection', (d) => { d.connections[id] = { id, fromCommitmentId, toTaskId, skill } as any; }); - const doc = _syncServer!.getDoc(tasksDocId(space))!; - return c.json(doc.connections[id], 201); + // Notify commitment owner that their time was requested + const cDoc = _syncServer!.getDoc(commitmentsDocId(space)); + const tDoc = _syncServer!.getDoc(tasksDocId(space))!; + const commitment = cDoc?.items?.[fromCommitmentId]; + const task = tDoc.tasks?.[toTaskId]; + 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) => { diff --git a/modules/rtime/schemas.ts b/modules/rtime/schemas.ts index 47c95e3..79d6237 100644 --- a/modules/rtime/schemas.ts +++ b/modules/rtime/schemas.ts @@ -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 ── diff --git a/server/notification-service.ts b/server/notification-service.ts index 1fef34c..770cf16 100644 --- a/server/notification-service.ts +++ b/server/notification-service.ts @@ -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; diff --git a/website/canvas.html b/website/canvas.html index b518179..b8cdc7c 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -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 @@ + + @@ -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": "✏️",