/** * rTime module — timebank commitment pool & weaving dashboard. * * Visualize community hour pledges as floating orbs in a basket, * then weave commitments into tasks on an SVG canvas. Optional * Cyclos integration for real timebank balances. * * All state stored in Automerge documents via SyncServer. * Doc layout: * {space}:rtime:commitments → CommitmentsDoc * {space}:rtime:tasks → TasksDoc */ import { Hono } from "hono"; import * as Automerge from '@automerge/automerge'; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyToken, extractToken } from "../../server/auth"; import { resolveCallerRole } from "../../server/spaces"; import type { SpaceRoleString } from "../../server/spaces"; import { filterArrayByVisibility } from "../../shared/membrane"; import { renderLanding } from "./landing"; import { notify } from '../../server/notification-service'; import type { SyncServer } from '../../server/local-first/sync-server'; import { commitmentsSchema, tasksSchema, weavingSchema, externalTimeLogsSchema, commitmentsDocId, tasksDocId, weavingDocId, externalTimeLogsDocId, SKILL_LABELS, } from './schemas'; import type { CommitmentsDoc, TasksDoc, WeavingDoc, ExternalTimeLogsDoc, Commitment, Task, Connection, ExecState, Skill, ExternalTimeLog, WeavingOverlay, } from './schemas'; import { boardDocId, createTaskItem } from '../rtasks/schemas'; import type { BoardDoc, TaskItem } from '../rtasks/schemas'; import { intentsSchema, solverResultsSchema, skillCurvesSchema, reputationSchema, } from './schemas-intent'; import { createIntentRoutes } from './intent-routes'; const routes = new Hono(); // ── SyncServer ref (set during onInit) ── let _syncServer: SyncServer | null = null; // ── Mount intent routes ── const intentRoutes = createIntentRoutes(() => _syncServer); routes.route('/', intentRoutes); // ── Automerge helpers ── function ensureCommitmentsDoc(space: string): CommitmentsDoc { const docId = commitmentsDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init commitments', (d) => { const init = commitmentsSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; }); _syncServer!.setDoc(docId, doc); } return doc; } function ensureTasksDoc(space: string): TasksDoc { const docId = tasksDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init tasks', (d) => { const init = tasksSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; }); _syncServer!.setDoc(docId, doc); } return doc; } function ensureWeavingDoc(space: string): WeavingDoc { const docId = weavingDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init weaving', (d) => { const init = weavingSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; }); _syncServer!.setDoc(docId, doc); } return doc; } function newId(): string { return crypto.randomUUID(); } // ── External Time Logs helpers ── function ensureExternalTimeLogsDoc(space: string): ExternalTimeLogsDoc { const docId = externalTimeLogsDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init external-time-logs', (d) => { const init = externalTimeLogsSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; }); _syncServer!.setDoc(docId, doc); } return doc; } // ── External Time Logs API (backlog-md integration) ── routes.post("/api/external-time-logs", 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 body = await c.req.json(); const { backlogTaskId, backlogTaskTitle, memberName, hours, skill, note, loggedAt } = body; if (!backlogTaskId || !memberName || !hours || !skill) { return c.json({ error: "backlogTaskId, memberName, hours, skill required" }, 400); } if (typeof hours !== 'number' || hours <= 0) { return c.json({ error: "hours must be a positive number" }, 400); } const VALID_SKILLS = ['facilitation', 'design', 'tech', 'outreach', 'logistics']; if (!VALID_SKILLS.includes(skill)) { return c.json({ error: `skill must be one of: ${VALID_SKILLS.join(', ')}` }, 400); } const id = newId(); const now = Date.now(); ensureExternalTimeLogsDoc(space); ensureCommitmentsDoc(space); // Create external time log entry _syncServer!.changeDoc(externalTimeLogsDocId(space), 'import time log', (d) => { d.logs[id] = { id, backlogTaskId, backlogTaskTitle: backlogTaskTitle || backlogTaskId, memberName, memberId: (claims.did as string) || undefined, hours, skill: skill as Skill, note: note || undefined, loggedAt: loggedAt || now, importedAt: now, status: 'pending', } as any; }); // Auto-create a commitment from this time log const commitmentId = newId(); _syncServer!.changeDoc(commitmentsDocId(space), 'auto-create commitment from time log', (d) => { d.items[commitmentId] = { id: commitmentId, memberName, hours: Math.max(1, Math.min(10, hours)), skill: skill as Skill, desc: note || `Time logged: ${backlogTaskTitle || backlogTaskId}`, createdAt: now, status: 'active', ownerDid: (claims.did as string) || '', } as any; }); // Link commitment to the time log _syncServer!.changeDoc(externalTimeLogsDocId(space), 'link commitment', (d) => { d.logs[id].commitmentId = commitmentId as any; d.logs[id].status = 'commitment_created' as any; }); // Auto-connect to weaving task if a linked task exists on canvas ensureWeavingDoc(space); const wDoc = _syncServer!.getDoc(weavingDocId(space)); const board = getWeavingBoard(space); if (wDoc && board) { // Find placed task whose rTasks title/desc contains backlogTaskId const placedIds = Object.keys(wDoc.weavingOverlays); const linkedTask = placedIds.find(tid => { const item = board.doc.tasks[tid]; return item && (item.description?.includes(backlogTaskId) || item.title?.includes(backlogTaskId)); }); if (linkedTask) { const connId = newId(); _syncServer!.changeDoc(weavingDocId(space), 'auto-connect time log to task', (d) => { d.connections[connId] = { id: connId, fromCommitmentId: commitmentId, toTaskId: linkedTask, skill, hours, status: 'committed', } as any; }); } } const doc = _syncServer!.getDoc(externalTimeLogsDocId(space))!; return c.json({ log: doc.logs[id], commitmentId }, 201); }); routes.get("/api/external-time-logs", (c) => { const space = c.req.param("space") || "demo"; ensureExternalTimeLogsDoc(space); const doc = _syncServer!.getDoc(externalTimeLogsDocId(space))!; let logs = Object.values(doc.logs); // Filter by query params const backlogTaskId = c.req.query("backlogTaskId"); const memberName = c.req.query("memberName"); const status = c.req.query("status"); if (backlogTaskId) logs = logs.filter(l => l.backlogTaskId === backlogTaskId); if (memberName) logs = logs.filter(l => l.memberName === memberName); if (status) logs = logs.filter(l => l.status === status); return c.json({ logs }); }); routes.post("/api/external-time-logs/:id/settle", 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 logId = c.req.param("id"); ensureExternalTimeLogsDoc(space); const doc = _syncServer!.getDoc(externalTimeLogsDocId(space))!; const log = doc.logs[logId]; if (!log) return c.json({ error: "Time log not found" }, 404); if (log.status === 'settled') return c.json({ error: "Already settled" }, 400); // Update log status to settled _syncServer!.changeDoc(externalTimeLogsDocId(space), 'settle time log', (d) => { d.logs[logId].status = 'settled' as any; }); // Update commitment status if (log.commitmentId) { ensureCommitmentsDoc(space); const cDoc = _syncServer!.getDoc(commitmentsDocId(space))!; if (cDoc.items[log.commitmentId]) { _syncServer!.changeDoc(commitmentsDocId(space), 'settle commitment', (d) => { d.items[log.commitmentId!].status = 'settled' as any; }); } } // Update reputation: self-attestation with neutral rating (3/5) const memberId = log.memberId || log.memberName; const { reputationDocId } = await import('./schemas-intent'); const repDocId = reputationDocId(space); const { reputationKey } = await import('./reputation'); const key = reputationKey(memberId, log.skill); const repDoc = _syncServer!.getDoc(repDocId); if (repDoc) { _syncServer!.changeDoc(repDocId, 'update reputation from settled time log', (d: any) => { if (!d.entries[key]) { d.entries[key] = { memberId, skill: log.skill, score: 50, completedHours: 0, ratings: [], }; } d.entries[key].completedHours = (d.entries[key].completedHours || 0) + log.hours; // Self-attestation: neutral 3/5 rating d.entries[key].ratings.push({ from: memberId, score: 3, timestamp: Date.now(), }); }); } // Update skill curves: add supply hours const { skillCurvesDocId } = await import('./schemas-intent'); const scDocId = skillCurvesDocId(space); const scDoc = _syncServer!.getDoc(scDocId); if (scDoc) { _syncServer!.changeDoc(scDocId, 'update skill curve from settled time log', (d: any) => { if (!d.curves[log.skill]) { d.curves[log.skill] = { skill: log.skill, supplyHours: 0, demandHours: 0, currentPrice: 100, history: [], }; } d.curves[log.skill].supplyHours = (d.curves[log.skill].supplyHours || 0) + log.hours; }); } return c.json({ ok: true, logId, status: 'settled' }); }); routes.post("/api/tasks/:id/link-backlog", 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); } const space = c.req.param("space") || "demo"; const taskId = c.req.param("id"); const body = await c.req.json(); const { backlogTaskId } = body; if (!backlogTaskId) return c.json({ error: "backlogTaskId required" }, 400); ensureTasksDoc(space); const doc = _syncServer!.getDoc(tasksDocId(space))!; if (!doc.tasks[taskId]) return c.json({ error: "Task not found" }, 404); // Store backlog task ID in the task description as a cross-reference _syncServer!.changeDoc(tasksDocId(space), 'link backlog task', (d) => { const t = d.tasks[taskId]; const ref = `[backlog:${backlogTaskId}]`; if (!t.description.includes(ref)) { t.description = t.description ? `${t.description}\n${ref}` : ref; } }); const updated = _syncServer!.getDoc(tasksDocId(space))!; return c.json(updated.tasks[taskId]); }); // ── Weave API (rTasks integration) ── /** Helper: find the rTasks board doc for a space's weaving canvas. */ function getWeavingBoard(space: string): { docId: string; doc: BoardDoc } | null { const wDoc = ensureWeavingDoc(space); const slug = wDoc.boardSlug || space; const docId = boardDocId(space, slug); const doc = _syncServer!.getDoc(docId); if (!doc) return null; return { docId, doc }; } routes.get("/api/weave", async (c) => { const space = c.req.param("space") || "demo"; let callerRole: SpaceRoleString = 'viewer'; const token = extractToken(c.req.raw.headers); if (token) { try { const claims = await verifyToken(token); const resolved = await resolveCallerRole(space, claims); if (resolved) callerRole = resolved.role; } catch {} } ensureWeavingDoc(space); const wDoc = _syncServer!.getDoc(weavingDocId(space))!; const board = getWeavingBoard(space); const allTasks: Record = board?.doc.tasks || {}; const placedIds = new Set(Object.keys(wDoc.weavingOverlays)); const placedTasks = Object.values(allTasks) .filter(t => placedIds.has(t.id)) .map(t => { const ov = wDoc.weavingOverlays[t.id]; return { id: t.id, title: t.title, status: t.status, description: t.description, priority: t.priority, labels: t.labels, assigneeId: t.assigneeId, dueDate: t.dueDate, needs: ov.needs, canvasX: ov.canvasX, canvasY: ov.canvasY, notes: ov.notes, links: ov.links, intentFrameId: ov.intentFrameId, }; }); const unplacedTasks = filterArrayByVisibility( Object.values(allTasks).filter(t => !placedIds.has(t.id)), callerRole, ); return c.json({ boardSlug: wDoc.boardSlug || space, placedTasks, unplacedTasks: unplacedTasks.map(t => ({ id: t.id, title: t.title, status: t.status, description: t.description, priority: t.priority, labels: t.labels, assigneeId: t.assigneeId, dueDate: t.dueDate, })), connections: Object.values(wDoc.connections), execStates: Object.values(wDoc.execStates), projectFrames: Object.values(wDoc.projectFrames), }); }); routes.post("/api/weave/bind-board", 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); } const space = c.req.param("space") || "demo"; const { boardSlug } = await c.req.json(); if (!boardSlug) return c.json({ error: "boardSlug required" }, 400); // Verify board exists const docId = boardDocId(space, boardSlug); if (!_syncServer!.getDoc(docId)) { return c.json({ error: "Board not found" }, 404); } ensureWeavingDoc(space); _syncServer!.changeDoc(weavingDocId(space), 'bind board', (d) => { d.boardSlug = boardSlug; }); return c.json({ ok: true, boardSlug }); }); routes.post("/api/weave/place/:rtasksId", 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); } const space = c.req.param("space") || "demo"; const rtasksId = c.req.param("rtasksId"); const body = await c.req.json(); const { needs, canvasX, canvasY, notes, links } = body; // Verify task exists in rTasks board const board = getWeavingBoard(space); if (!board || !board.doc.tasks[rtasksId]) { return c.json({ error: "Task not found in rTasks board" }, 404); } ensureWeavingDoc(space); _syncServer!.changeDoc(weavingDocId(space), 'place task on canvas', (d) => { d.weavingOverlays[rtasksId] = { rtasksId, needs: needs || {}, canvasX: canvasX ?? 400, canvasY: canvasY ?? 150, notes: notes || '', links: links || [], } as any; }); const wDoc = _syncServer!.getDoc(weavingDocId(space))!; return c.json(wDoc.weavingOverlays[rtasksId], 201); }); routes.delete("/api/weave/place/:rtasksId", 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); } const space = c.req.param("space") || "demo"; const rtasksId = c.req.param("rtasksId"); ensureWeavingDoc(space); const wDoc = _syncServer!.getDoc(weavingDocId(space))!; if (!wDoc.weavingOverlays[rtasksId]) return c.json({ error: "Not placed" }, 404); _syncServer!.changeDoc(weavingDocId(space), 'remove from canvas', (d) => { delete d.weavingOverlays[rtasksId]; // Remove associated connections for (const [connId, conn] of Object.entries(d.connections)) { if (conn.toTaskId === rtasksId) delete d.connections[connId]; } // Remove exec state delete d.execStates[rtasksId]; }); return c.json({ ok: true }); }); routes.put("/api/weave/overlay/:rtasksId", 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); } const space = c.req.param("space") || "demo"; const rtasksId = c.req.param("rtasksId"); const body = await c.req.json(); ensureWeavingDoc(space); const wDoc = _syncServer!.getDoc(weavingDocId(space))!; if (!wDoc.weavingOverlays[rtasksId]) return c.json({ error: "Not placed" }, 404); _syncServer!.changeDoc(weavingDocId(space), 'update overlay', (d) => { const ov = d.weavingOverlays[rtasksId]; if (body.needs !== undefined) ov.needs = body.needs; if (body.canvasX !== undefined) ov.canvasX = body.canvasX; if (body.canvasY !== undefined) ov.canvasY = body.canvasY; if (body.notes !== undefined) ov.notes = body.notes; if (body.links !== undefined) ov.links = body.links; if (body.intentFrameId !== undefined) ov.intentFrameId = body.intentFrameId; }); const updated = _syncServer!.getDoc(weavingDocId(space))!; return c.json(updated.weavingOverlays[rtasksId]); }); routes.post("/api/weave/create-and-place", 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 body = await c.req.json(); const { title, description, needs, canvasX, canvasY, intentFrameId } = body; if (!title) return c.json({ error: "title required" }, 400); // Find or create the board const board = getWeavingBoard(space); if (!board) return c.json({ error: "No rTasks board bound" }, 404); // Create TaskItem in rTasks const taskId = newId(); const taskItem = createTaskItem(taskId, space, title, { description: description || '', createdBy: (claims.did as string) || null, }); _syncServer!.changeDoc(board.docId, 'create task from weave', (d) => { d.tasks[taskId] = taskItem as any; }); // Create WeavingOverlay ensureWeavingDoc(space); _syncServer!.changeDoc(weavingDocId(space), 'place new task on canvas', (d) => { d.weavingOverlays[taskId] = { rtasksId: taskId, needs: needs || {}, canvasX: canvasX ?? 400, canvasY: canvasY ?? 150, notes: '', links: [], intentFrameId, } as any; }); return c.json({ taskId, title, needs: needs || {} }, 201); }); // ── Exec State API (updated: write to WeavingDoc) ── routes.put("/api/weave/exec-state/:taskId", 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); } const space = c.req.param("space") || "demo"; const taskId = c.req.param("taskId"); const body = await c.req.json(); const { steps, launchedAt } = body; ensureWeavingDoc(space); _syncServer!.changeDoc(weavingDocId(space), 'update exec state', (d) => { if (!d.execStates[taskId]) { d.execStates[taskId] = { taskId, steps: {}, launchedAt: undefined } as any; } if (steps) d.execStates[taskId].steps = steps; if (launchedAt) d.execStates[taskId].launchedAt = launchedAt; }); const doc = _syncServer!.getDoc(weavingDocId(space))!; return c.json(doc.execStates[taskId]); }); // ── Cyclos proxy config ── const CYCLOS_URL = process.env.CYCLOS_URL || ''; const CYCLOS_API_KEY = process.env.CYCLOS_API_KEY || ''; function cyclosHeaders(): Record { const h: Record = { 'Content-Type': 'application/json' }; if (CYCLOS_API_KEY) h['Authorization'] = `Basic ${Buffer.from(CYCLOS_API_KEY).toString('base64')}`; return h; } // ── Demo seeding ── const DEMO_COMMITMENTS: Omit[] = [ { memberName: 'Maya Chen', hours: 3, skill: 'facilitation', desc: 'Circle facilitation for group sessions' }, { memberName: 'Jordan Rivera', hours: 2, skill: 'design', desc: 'Event poster and social media graphics' }, { memberName: 'Sam Okafor', hours: 4, skill: 'tech', desc: 'Website updates and form setup' }, { memberName: 'Priya Sharma', hours: 2, skill: 'outreach', desc: 'Community outreach and flyering' }, { memberName: 'Alex Kim', hours: 1, skill: 'logistics', desc: 'Venue setup and teardown' }, { memberName: 'Taylor Brooks', hours: 3, skill: 'facilitation', desc: 'Harm reduction education session' }, { memberName: 'Robin Patel', hours: 2, skill: 'design', desc: 'Printed resource cards' }, { memberName: 'Casey Morgan', hours: 2, skill: 'logistics', desc: 'Supply procurement and transport' }, ]; const DEMO_SEED_TASKS: { title: string; needs: Record }[] = [ { title: 'Organize Community Event', needs: { facilitation: 3, design: 2, outreach: 2, logistics: 2 } }, { title: 'Run Harm Reduction Workshop', needs: { facilitation: 4, design: 2, tech: 4, logistics: 1 } }, ]; function seedDemoIfEmpty(space: string = 'demo') { if (!_syncServer) return; const existing = _syncServer.getDoc(commitmentsDocId(space)); if (existing?.meta?.seeded) return; ensureCommitmentsDoc(space); const now = Date.now(); _syncServer.changeDoc(commitmentsDocId(space), 'seed commitments', (d) => { for (const c of DEMO_COMMITMENTS) { const id = newId(); d.items[id] = { id, ...c, createdAt: now } as any; } (d.meta as any).seeded = true; }); // Seed tasks into rTasks board + place on weaving canvas const board = getWeavingBoard(space); if (board) { ensureWeavingDoc(space); let canvasX = 300; for (const t of DEMO_SEED_TASKS) { const id = newId(); const taskItem = createTaskItem(id, space, t.title, { description: '' }); _syncServer.changeDoc(board.docId, 'seed rtime task', (d) => { d.tasks[id] = taskItem as any; }); _syncServer.changeDoc(weavingDocId(space), 'seed weaving overlay', (d) => { d.weavingOverlays[id] = { rtasksId: id, needs: t.needs, canvasX, canvasY: 150, notes: '', links: [], } as any; }); canvasX += 280; } console.log(`[rTime] Demo seeded for "${space}": ${DEMO_COMMITMENTS.length} commitments, ${DEMO_SEED_TASKS.length} tasks (rTasks + weaving)`); } else { // Fallback: seed into legacy TasksDoc ensureTasksDoc(space); _syncServer.changeDoc(tasksDocId(space), 'seed tasks (legacy)', (d) => { for (const t of DEMO_SEED_TASKS) { const id = newId(); d.tasks[id] = { id, name: t.title, description: '', needs: t.needs, links: [], notes: '' } as any; } }); console.log(`[rTime] Demo seeded for "${space}": ${DEMO_COMMITMENTS.length} commitments, ${DEMO_SEED_TASKS.length} tasks (legacy)`); } } // ── Commitments API ── routes.get("/api/commitments", async (c) => { const space = c.req.param("space") || "demo"; // Resolve caller role for membrane filtering let callerRole: SpaceRoleString = 'viewer'; const token = extractToken(c.req.raw.headers); if (token) { try { const claims = await verifyToken(token); const resolved = await resolveCallerRole(space, claims); if (resolved) callerRole = resolved.role; } catch {} } ensureCommitmentsDoc(space); const doc = _syncServer!.getDoc(commitmentsDocId(space))!; const items = filterArrayByVisibility(Object.values(doc.items), callerRole); return c.json({ commitments: items }); }); routes.post("/api/commitments", 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 body = await c.req.json(); const { memberName, hours, skill, desc } = body; if (!memberName || !hours || !skill) return c.json({ error: "memberName, hours, skill required" }, 400); const id = newId(); const now = Date.now(); 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, ownerDid: (claims.did as string) || '' } as any; }); const doc = _syncServer!.getDoc(commitmentsDocId(space))!; return c.json(doc.items[id], 201); }); routes.delete("/api/commitments/:id", 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); } const space = c.req.param("space") || "demo"; const id = c.req.param("id"); ensureCommitmentsDoc(space); const doc = _syncServer!.getDoc(commitmentsDocId(space))!; if (!doc.items[id]) return c.json({ error: "Not found" }, 404); _syncServer!.changeDoc(commitmentsDocId(space), 'remove commitment', (d) => { delete d.items[id]; }); return c.json({ ok: true }); }); // ── Tasks API (compat shim — reads from WeavingDoc + rTasks board) ── routes.get("/api/tasks", async (c) => { const space = c.req.param("space") || "demo"; let callerRole: SpaceRoleString = 'viewer'; const token = extractToken(c.req.raw.headers); if (token) { try { const claims = await verifyToken(token); const resolved = await resolveCallerRole(space, claims); if (resolved) callerRole = resolved.role; } catch {} } ensureWeavingDoc(space); const wDoc = _syncServer!.getDoc(weavingDocId(space))!; const board = getWeavingBoard(space); // Build legacy-shaped task list from placed overlays + rTasks items const tasks: any[] = []; for (const [id, ov] of Object.entries(wDoc.weavingOverlays)) { const item = board?.doc.tasks[id]; tasks.push({ id, name: item?.title || id, description: item?.description || '', needs: ov.needs, links: ov.links, notes: ov.notes, intentFrameId: ov.intentFrameId, }); } // Also include legacy TasksDoc tasks if any (migration compat) const legacyDoc = _syncServer!.getDoc(tasksDocId(space)); if (legacyDoc) { for (const t of Object.values(legacyDoc.tasks)) { if (!wDoc.weavingOverlays[t.id]) { tasks.push(t); } } } return c.json({ tasks: filterArrayByVisibility(tasks, callerRole), connections: Object.values(wDoc.connections), execStates: Object.values(wDoc.execStates), }); }); // POST /api/tasks — compat shim: creates rTasks item + places on canvas routes.post("/api/tasks", 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 body = await c.req.json(); const { name, description, needs, intentFrameId } = body; if (!name || !needs) return c.json({ error: "name, needs required" }, 400); const board = getWeavingBoard(space); if (!board) { // Fallback to legacy TasksDoc if no board bound const id = newId(); ensureTasksDoc(space); _syncServer!.changeDoc(tasksDocId(space), 'add task (legacy)', (d) => { d.tasks[id] = { id, name, description: description || '', needs, links: [], notes: '' } as any; }); const doc = _syncServer!.getDoc(tasksDocId(space))!; return c.json(doc.tasks[id], 201); } // Create in rTasks + WeavingDoc const taskId = newId(); const taskItem = createTaskItem(taskId, space, name, { description: description || '', createdBy: (claims.did as string) || null, }); _syncServer!.changeDoc(board.docId, 'create task from rtime compat', (d) => { d.tasks[taskId] = taskItem as any; }); ensureWeavingDoc(space); _syncServer!.changeDoc(weavingDocId(space), 'place task (compat)', (d) => { d.weavingOverlays[taskId] = { rtasksId: taskId, needs: needs || {}, canvasX: 400, canvasY: 150, notes: '', links: [], intentFrameId, } as any; }); return c.json({ id: taskId, name, description: description || '', needs, links: [], notes: '' }, 201); }); // PUT /api/tasks/:id — compat shim: updates rTasks title/desc + WeavingDoc overlay routes.put("/api/tasks/:id", 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); } const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const body = await c.req.json(); // Update rTasks item (title, description) const board = getWeavingBoard(space); if (board?.doc.tasks[id]) { _syncServer!.changeDoc(board.docId, 'update task from rtime', (d) => { const t = d.tasks[id]; if (body.name !== undefined) t.title = body.name; if (body.description !== undefined) t.description = body.description; t.updatedAt = Date.now(); }); } // Update WeavingDoc overlay (needs, notes, links) ensureWeavingDoc(space); const wDoc = _syncServer!.getDoc(weavingDocId(space))!; if (wDoc.weavingOverlays[id]) { _syncServer!.changeDoc(weavingDocId(space), 'update overlay (compat)', (d) => { const ov = d.weavingOverlays[id]; if (body.needs !== undefined) ov.needs = body.needs; if (body.links !== undefined) ov.links = body.links; if (body.notes !== undefined) ov.notes = body.notes; }); } else { // Legacy fallback ensureTasksDoc(space); const legacyDoc = _syncServer!.getDoc(tasksDocId(space))!; if (legacyDoc.tasks[id]) { _syncServer!.changeDoc(tasksDocId(space), 'update task (legacy)', (d) => { const t = d.tasks[id]; if (body.name !== undefined) t.name = body.name; if (body.description !== undefined) t.description = body.description; if (body.needs !== undefined) t.needs = body.needs; if (body.links !== undefined) t.links = body.links; if (body.notes !== undefined) t.notes = body.notes; }); } else { return c.json({ error: "Not found" }, 404); } } // Return merged view const item = board?.doc ? _syncServer!.getDoc(board.docId)?.tasks[id] : null; const ov = _syncServer!.getDoc(weavingDocId(space))?.weavingOverlays[id]; return c.json({ id, name: item?.title || id, description: item?.description || '', needs: ov?.needs || {}, links: ov?.links || [], notes: ov?.notes || '', }); }); // ── Connections API (now writes to WeavingDoc) ── routes.post("/api/connections", 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 body = await c.req.json(); 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(); ensureWeavingDoc(space); ensureCommitmentsDoc(space); // Validate: hours <= commitment's available hours const cDoc = _syncServer!.getDoc(commitmentsDocId(space)); const commitment = cDoc?.items?.[fromCommitmentId]; if (!commitment) return c.json({ error: "Commitment not found" }, 404); const wDoc = _syncServer!.getDoc(weavingDocId(space))!; // Validate task is placed on canvas if (!wDoc.weavingOverlays[toTaskId]) { return c.json({ error: "Task not placed on weaving canvas" }, 400); } const usedHours = Object.values(wDoc.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(weavingDocId(space), 'add connection', (d) => { d.connections[id] = { id, fromCommitmentId, toTaskId, skill, hours, status: 'proposed' } as any; }); // Notify commitment owner const board = getWeavingBoard(space); const taskItem = board?.doc.tasks[toTaskId]; if (commitment?.ownerDid && commitment.ownerDid !== claims.did) { notify({ userDid: commitment.ownerDid, category: 'module', eventType: 'commitment_requested', title: `${hours}hr of your ${commitment.hours}hr ${skill} commitment was requested`, body: taskItem ? `Task: ${taskItem.title}` : undefined, spaceSlug: space, moduleId: 'rtime', actionUrl: `/rtime`, actorDid: claims.did as string | undefined, metadata: { resultId: id, fromCommitmentId }, }).catch(() => {}); } const updatedW = _syncServer!.getDoc(weavingDocId(space))!; return c.json(updatedW.connections[id], 201); }); routes.delete("/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"); ensureWeavingDoc(space); ensureCommitmentsDoc(space); const wDoc = _syncServer!.getDoc(weavingDocId(space))!; const connection = wDoc.connections[id]; if (!connection) return c.json({ error: "Not found" }, 404); const cDoc = _syncServer!.getDoc(commitmentsDocId(space)); const commitment = cDoc?.items?.[connection.fromCommitmentId]; _syncServer!.changeDoc(weavingDocId(space), 'remove connection', (d) => { delete d.connections[id]; }); if (commitment?.ownerDid && commitment.ownerDid !== (claims.did as string)) { notify({ userDid: commitment.ownerDid, category: 'module', eventType: 'commitment_declined', title: `Your ${commitment.hours}hr ${commitment.skill} commitment request was declined`, spaceSlug: space, moduleId: 'rtime', actionUrl: `/rtime`, actorDid: claims.did as string | undefined, }).catch(() => {}); } 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); ensureWeavingDoc(space); ensureCommitmentsDoc(space); const wDoc = _syncServer!.getDoc(weavingDocId(space))!; const connection = wDoc.connections[id]; if (!connection) return c.json({ error: "Not found" }, 404); if (status === 'declined') { _syncServer!.changeDoc(weavingDocId(space), 'decline connection', (d) => { delete d.connections[id]; }); return c.json({ ok: true, deleted: true }); } _syncServer!.changeDoc(weavingDocId(space), 'approve connection', (d) => { d.connections[id].status = 'committed' as any; }); const updated = _syncServer!.getDoc(weavingDocId(space))!; return c.json(updated.connections[id]); }); // ── Exec State API (compat — redirects to WeavingDoc) ── routes.put("/api/tasks/:id/exec-state", 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); } const space = c.req.param("space") || "demo"; const taskId = c.req.param("id"); const body = await c.req.json(); const { steps, launchedAt } = body; ensureWeavingDoc(space); _syncServer!.changeDoc(weavingDocId(space), 'update exec state', (d) => { if (!d.execStates[taskId]) { d.execStates[taskId] = { taskId, steps: {}, launchedAt: undefined } as any; } if (steps) d.execStates[taskId].steps = steps; if (launchedAt) d.execStates[taskId].launchedAt = launchedAt; }); const doc = _syncServer!.getDoc(weavingDocId(space))!; return c.json(doc.execStates[taskId]); }); // ── Cyclos proxy API (graceful no-op when CYCLOS_URL not set) ── routes.get("/api/cyclos/members", async (c) => { if (!CYCLOS_URL) return c.json({ error: "Cyclos not configured", members: [] }); try { const resp = await fetch(`${CYCLOS_URL}/api/users?roles=member&fields=id,display,email`, { headers: cyclosHeaders() }); if (!resp.ok) throw new Error(`Cyclos ${resp.status}`); const users = await resp.json() as any[]; const members = await Promise.all(users.map(async (u: any) => { try { const balResp = await fetch(`${CYCLOS_URL}/api/${u.id}/accounts`, { headers: cyclosHeaders() }); const accounts = balResp.ok ? await balResp.json() as any[] : []; const balance = accounts[0]?.status?.balance || '0'; return { id: u.id, name: u.display, email: u.email, balance: parseFloat(balance) }; } catch { return { id: u.id, name: u.display, email: u.email, balance: 0 }; } })); return c.json({ members }); } catch (err: any) { return c.json({ error: 'Failed to fetch from Cyclos', members: [] }, 502); } }); routes.post("/api/cyclos/commitments", async (c) => { if (!CYCLOS_URL) return c.json({ error: "Cyclos not configured" }, 501); const body = await c.req.json(); const { fromUserId, amount, description } = body; if (!fromUserId || !amount) return c.json({ error: "fromUserId, amount required" }, 400); try { const resp = await fetch(`${CYCLOS_URL}/api/${fromUserId}/payments`, { method: 'POST', headers: cyclosHeaders(), body: JSON.stringify({ type: 'user.toSystem', amount: String(amount), description: description || 'Commitment', subject: 'system' }), }); if (!resp.ok) throw new Error(await resp.text()); const result = await resp.json(); return c.json(result); } catch (err: any) { return c.json({ error: 'Cyclos commitment failed' }, 502); } }); routes.post("/api/cyclos/transfers", async (c) => { if (!CYCLOS_URL) return c.json({ error: "Cyclos not configured" }, 501); const body = await c.req.json(); const { fromUserId, toUserId, amount, description } = body; if (!fromUserId || !toUserId || !amount) return c.json({ error: "fromUserId, toUserId, amount required" }, 400); try { const resp = await fetch(`${CYCLOS_URL}/api/${fromUserId}/payments`, { method: 'POST', headers: cyclosHeaders(), body: JSON.stringify({ type: 'user.toUser', amount: String(amount), description: description || 'Hour transfer', subject: toUserId }), }); if (!resp.ok) throw new Error(await resp.text()); const result = await resp.json(); return c.json(result); } catch (err: any) { return c.json({ error: 'Cyclos transfer failed' }, 502); } }); // ── Page routes ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${space} — rTime | rSpace`, moduleId: "rtime", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ``, styles: ``, })); }); routes.post("/api/tasks/:id/export-to-backlog", 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); } const space = c.req.param("space") || "demo"; const taskId = c.req.param("id"); // Try WeavingDoc + rTasks first, fallback to legacy TasksDoc ensureWeavingDoc(space); const wDoc = _syncServer!.getDoc(weavingDocId(space))!; const ov = wDoc.weavingOverlays[taskId]; const board = getWeavingBoard(space); const item = board?.doc.tasks[taskId]; if (ov && item) { const estimatedHours = Object.values(ov.needs).reduce((sum, h) => sum + h, 0); const acceptanceCriteria = Object.entries(ov.needs).map(([skill, hours]) => `${SKILL_LABELS[skill as Skill] || skill}: ${hours}h` ); return c.json({ title: item.title, description: item.description?.replace(/\[backlog:[^\]]+\]/g, '').trim() || '', estimatedHours, labels: Object.keys(ov.needs), notes: ov.notes || '', acceptanceCriteria, rtime: { taskId, space }, }); } // Legacy fallback ensureTasksDoc(space); const doc = _syncServer!.getDoc(tasksDocId(space))!; const task = doc.tasks[taskId]; if (!task) return c.json({ error: "Task not found" }, 404); const estimatedHours = Object.values(task.needs).reduce((sum, h) => sum + h, 0); const acceptanceCriteria = Object.entries(task.needs).map(([skill, hours]) => `${SKILL_LABELS[skill as Skill] || skill}: ${hours}h` ); return c.json({ title: task.name, description: task.description?.replace(/\[backlog:[^\]]+\]/g, '').trim() || '', estimatedHours, labels: Object.keys(task.needs), notes: task.notes || '', acceptanceCriteria, rtime: { taskId: task.id, space }, }); }); routes.get("/canvas", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${space} — Canvas | rTime | rSpace`, moduleId: "rtime", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ``, styles: ``, })); }); routes.get("/collaborate", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${space} — Collaborate | rTime | rSpace`, moduleId: "rtime", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ``, styles: ``, })); }); routes.get("/dashboard", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${space} — Fulfillment | rTime | rSpace`, moduleId: "rtime", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ``, styles: ``, })); }); // ── Module export ── export const timeModule: RSpaceModule = { id: "rtime", name: "rTime", icon: "⏳", description: "Timebank commitment pool & weaving dashboard", canvasShapes: ["folk-commitment-pool", "folk-task-request"], canvasToolIds: ["create_commitment_pool", "create_task_request"], scoping: { defaultScope: 'space', userConfigurable: false }, docSchemas: [ { pattern: '{space}:rtime:commitments', description: 'Commitment pool', init: commitmentsSchema.init }, { pattern: '{space}:rtime:tasks', description: 'Tasks, connections, exec states (legacy)', init: tasksSchema.init }, { pattern: '{space}:rtime:weaving', description: 'Weaving overlays, connections, exec states (rTasks integration)', init: weavingSchema.init }, { pattern: '{space}:rtime:intents', description: 'Intent pool (offers & needs)', init: intentsSchema.init }, { pattern: '{space}:rtime:solver-results', description: 'Solver collaboration recommendations', init: solverResultsSchema.init }, { pattern: '{space}:rtime:skill-curves', description: 'Per-skill demand/supply pricing', init: skillCurvesSchema.init }, { pattern: '{space}:rtime:reputation', description: 'Per-member per-skill reputation', init: reputationSchema.init }, { pattern: '{space}:rtime:external-time-logs', description: 'External time logs from backlog-md', init: externalTimeLogsSchema.init }, ], routes, landingPage: renderLanding, seedTemplate: seedDemoIfEmpty, async onInit(ctx) { _syncServer = ctx.syncServer; seedDemoIfEmpty(); }, feeds: [ { id: "commitments", name: "Commitments", kind: "economic", description: "Hour pledges from community members", filterable: true, }, ], // Views (Canvas, Collaborate, Fulfillment) are handled by the component's // internal tab-bar — no outputPaths needed to avoid duplicate navigation. onboardingActions: [ { label: "Pledge Hours", icon: "⏳", description: "Add a commitment to the pool", type: 'create', href: '/rtime' }, ], }; // ── MI Data Export ── export interface MICommitmentItem { id: string; memberName: string; hours: number; skill: string; desc: string; status: string; } export function getRecentCommitmentsForMI(space: string, limit = 5): MICommitmentItem[] { if (!_syncServer) return []; const doc = _syncServer.getDoc(commitmentsDocId(space)); if (!doc?.items) return []; return Object.values(doc.items) .filter(c => (c.status || "active") === "active") .sort((a, b) => b.createdAt - a.createdAt) .slice(0, limit) .map(c => ({ id: c.id, memberName: c.memberName, hours: c.hours, skill: c.skill, desc: (c.desc || "").slice(0, 200), status: c.status || "active", })); }