/** * 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 { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { commitmentsSchema, tasksSchema, commitmentsDocId, tasksDocId, } from './schemas'; import type { CommitmentsDoc, TasksDoc, Commitment, Task, Connection, ExecState, Skill, } from './schemas'; const routes = new Hono(); // ── SyncServer ref (set during onInit) ── let _syncServer: SyncServer | null = null; // ── 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 newId(): string { return crypto.randomUUID(); } // ── 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_TASKS: Omit[] = [ { name: 'Organize Community Event', description: '', needs: { facilitation: 3, design: 2, outreach: 2, logistics: 2 }, links: [], notes: '' }, { name: 'Run Harm Reduction Workshop', description: '', needs: { facilitation: 4, design: 2, tech: 4, logistics: 1 }, links: [], notes: '' }, ]; 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; }); ensureTasksDoc(space); _syncServer.changeDoc(tasksDocId(space), 'seed tasks', (d) => { for (const t of DEMO_TASKS) { const id = newId(); d.tasks[id] = { id, ...t } as any; } }); console.log(`[rTime] Demo data seeded for "${space}": ${DEMO_COMMITMENTS.length} commitments, ${DEMO_TASKS.length} tasks`); } // ── Commitments API ── routes.get("/api/commitments", (c) => { const space = c.req.param("space") || "demo"; ensureCommitmentsDoc(space); const doc = _syncServer!.getDoc(commitmentsDocId(space))!; const items = Object.values(doc.items); 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 } 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 ── routes.get("/api/tasks", (c) => { const space = c.req.param("space") || "demo"; ensureTasksDoc(space); const doc = _syncServer!.getDoc(tasksDocId(space))!; return c.json({ tasks: Object.values(doc.tasks), connections: Object.values(doc.connections), execStates: Object.values(doc.execStates), }); }); routes.post("/api/tasks", 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 body = await c.req.json(); const { name, description, needs } = body; if (!name || !needs) return c.json({ error: "name, needs required" }, 400); const id = newId(); ensureTasksDoc(space); _syncServer!.changeDoc(tasksDocId(space), 'add task', (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); }); 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(); ensureTasksDoc(space); const doc = _syncServer!.getDoc(tasksDocId(space))!; if (!doc.tasks[id]) return c.json({ error: "Not found" }, 404); _syncServer!.changeDoc(tasksDocId(space), 'update task', (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; }); const updated = _syncServer!.getDoc(tasksDocId(space))!; return c.json(updated.tasks[id]); }); // ── Connections API ── 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); } const space = c.req.param("space") || "demo"; const body = await c.req.json(); const { fromCommitmentId, toTaskId, skill } = body; if (!fromCommitmentId || !toTaskId || !skill) return c.json({ error: "fromCommitmentId, toTaskId, skill required" }, 400); const id = newId(); ensureTasksDoc(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); }); routes.delete("/api/connections/: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"); ensureTasksDoc(space); const doc = _syncServer!.getDoc(tasksDocId(space))!; if (!doc.connections[id]) return c.json({ error: "Not found" }, 404); _syncServer!.changeDoc(tasksDocId(space), 'remove connection', (d) => { delete d.connections[id]; }); return c.json({ ok: true }); }); // ── Exec State API ── 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; ensureTasksDoc(space); _syncServer!.changeDoc(tasksDocId(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(tasksDocId(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: ``, })); }); // ── Module export ── export const timeModule: RSpaceModule = { id: "rtime", name: "rTime", icon: "⏳", description: "Timebank commitment pool & weaving dashboard", 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', init: tasksSchema.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, }, ], outputPaths: [ { path: "commitments", name: "Commitments", icon: "🧺", description: "Community hour pledges" }, { path: "weave", name: "Weave", icon: "🧶", description: "Task weaving dashboard" }, ], onboardingActions: [ { label: "Pledge Hours", icon: "⏳", description: "Add a commitment to the pool", type: 'create', href: '/{space}/rtime' }, ], };