feat(rtime): add timebank commitment pool & weaving dashboard rApp

Port hcc-mem-staging SPA into rSpace as the rTime module. Canvas-based
commitment pool with physics orbs, SVG weaving editor with hex nodes
and bezier wires, execution panel, and optional Cyclos timebank proxy.
Automerge CRDT persistence, demo seeding, and full landing page
explaining community-based ledgers and emergent collaboration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-30 23:42:49 -07:00
parent 36ae954da4
commit b5c6477f47
8 changed files with 2540 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
/* rTime module — minimal external CSS (most styling in shadow DOM) */
folk-timebank-app {
display: flex;
flex-direction: column;
height: calc(100vh - var(--rs-header-height, 56px));
min-height: 0;
}

296
modules/rtime/landing.ts Normal file
View File

@ -0,0 +1,296 @@
/**
* rTime landing page community-based timebanking, commitment pooling,
* and emergent collaboration through shared hour ledgers.
*/
export function renderLanding(): string {
return `
<!-- Hero -->
<div class="rl-hero">
<span class="rl-tagline" style="color:#a78bfa;background:rgba(167,139,250,0.1);border-color:rgba(167,139,250,0.2)">
Part of the rSpace Ecosystem
</span>
<h1 class="rl-heading" style="background:linear-gradient(to right,#8b5cf6,#ec4899);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;font-size:2.5rem">
Community<br>Timebanking
</h1>
<p class="rl-subtitle">
A <strong style="color:#e2e8f0">community-based ledger</strong> for pooling time commitments
and weaving them into coordinated action. Every hour pledged becomes a visible,
connectable resource that your community can match to real needs.
</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rtime" class="rl-cta-primary"
style="background:linear-gradient(to right,#8b5cf6,#ec4899);color:white">
<span style="display:inline-flex;align-items:center;gap:0.5rem">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5,3 19,12 5,21"/></svg>
Try the Demo
</span>
</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
</div>
<!-- ELI5: What is Timebanking? -->
<section class="rl-section" style="border-top:none">
<div class="rl-container">
<div style="text-align:center;margin-bottom:2rem">
<span class="rl-badge" style="background:#1e293b;color:#94a3b8;font-size:0.7rem;padding:0.25rem 0.75rem">ELI5</span>
<h2 class="rl-heading" style="margin-top:0.75rem;background:linear-gradient(135deg,#e2e8f0,#cbd5e1);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
What is Timebanking?
</h2>
<p style="font-size:1.05rem;color:#94a3b8;max-width:640px;margin:0.5rem auto 0">
A <strong style="color:#a78bfa">community-based ledger</strong> where
<strong style="color:#ec4899">everyone's hour is worth the same</strong>.
Members pledge time, match skills to needs, and build trust through mutual aid.
</p>
</div>
<div class="rl-grid-3">
<!-- Commitment Pooling -->
<div class="rl-card" style="border:2px solid rgba(139,92,246,0.35);background:linear-gradient(to bottom right,rgba(139,92,246,0.08),rgba(139,92,246,0.03))">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
<div style="width:2rem;height:2rem;border-radius:9999px;background:#8b5cf6;display:flex;align-items:center;justify-content:center">
<span style="color:white;font-weight:700;font-size:0.8rem">1h</span>
</div>
<h3 style="color:#a78bfa;font-size:1.05rem;margin-bottom:0">Commitment Pooling</h3>
</div>
<p>
Members pledge hours with a skill category. Each pledge becomes an orb in the pool &mdash;
visible, weighted by hours, colored by skill.
<strong style="display:block;margin-top:0.5rem;color:#e2e8f0">See your community's capacity at a glance.</strong>
</p>
</div>
<!-- Community Ledger -->
<div class="rl-card" style="border:2px solid rgba(236,72,153,0.35);background:linear-gradient(to bottom right,rgba(236,72,153,0.08),rgba(236,72,153,0.03))">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
<div style="width:2rem;height:2rem;border-radius:9999px;background:#ec4899;display:flex;align-items:center;justify-content:center">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
</div>
<h3 style="color:#f472b6;font-size:1.05rem;margin-bottom:0">Community Ledger</h3>
</div>
<p>
Every hour is equal: one hour of facilitation = one hour of tech work.
The ledger tracks pledges, fulfillments, and transfers without money.
<strong style="display:block;margin-top:0.5rem;color:#e2e8f0">Built on CRDTs &mdash; works offline, syncs everywhere.</strong>
</p>
</div>
<!-- Emergent Collaboration -->
<div class="rl-card" style="border:2px solid rgba(16,185,129,0.35);background:linear-gradient(to bottom right,rgba(16,185,129,0.08),rgba(16,185,129,0.03))">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
<div style="width:2rem;height:2rem;border-radius:9999px;background:#10b981;display:flex;align-items:center;justify-content:center">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v6M12 17v6M4.22 4.22l4.24 4.24M15.54 15.54l4.24 4.24M1 12h6M17 12h6M4.22 19.78l4.24-4.24M15.54 8.46l4.24-4.24"/></svg>
</div>
<h3 style="color:#34d399;font-size:1.05rem;margin-bottom:0">Emergent Collaboration</h3>
</div>
<p>
Projects emerge from the pool: when enough skills converge on a task, the team
self-assembles. No top-down assignment needed.
<strong style="display:block;margin-top:0.5rem;color:#e2e8f0">Coordination that grows from the bottom up.</strong>
</p>
</div>
</div>
</div>
</section>
<!-- How It Works: Pool Weave Execute -->
<section class="rl-section rl-section--alt">
<div class="rl-container">
<div style="text-align:center;margin-bottom:2.5rem">
<span class="rl-tagline" style="color:#a78bfa;background:rgba(167,139,250,0.1);border-color:rgba(167,139,250,0.2)">
How It Works
</span>
<h2 class="rl-heading" style="background:linear-gradient(135deg,#e2e8f0,#cbd5e1);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Pool &rarr; Weave &rarr; Execute
</h2>
<p style="font-size:1.05rem;color:#94a3b8;max-width:640px;margin:0 auto">
Three stages turn scattered availability into running projects.
</p>
</div>
<div class="rl-grid-3">
<!-- Stage 1 -->
<div class="rl-card" style="border-color:rgba(139,92,246,0.2)">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
<div style="width:2.5rem;height:2.5rem;border-radius:9999px;background:linear-gradient(to bottom right,#8b5cf6,rgba(139,92,246,0.6));display:flex;align-items:center;justify-content:center">
<span style="color:white;font-weight:700;font-size:1rem">1</span>
</div>
<div>
<span class="rl-badge" style="background:rgba(139,92,246,0.1);color:#a78bfa;margin-bottom:0.25rem">Stage 1</span>
<h3 style="margin-bottom:0;font-size:1rem">Commitment Pool</h3>
</div>
</div>
<p>
Members pledge hours with a skill: facilitation, design, tech, outreach, or logistics.
Each pledge appears as a floating orb in the <strong style="color:#e2e8f0">commitment basket</strong>.
The pool gives everyone a live dashboard of available community capacity.
</p>
</div>
<!-- Stage 2 -->
<div class="rl-card" style="border-color:rgba(236,72,153,0.25);background:linear-gradient(to bottom right,rgba(236,72,153,0.05),rgba(139,92,246,0.03))">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
<div style="width:2.5rem;height:2.5rem;border-radius:9999px;background:linear-gradient(to bottom right,#ec4899,#8b5cf6);display:flex;align-items:center;justify-content:center">
<span style="color:white;font-weight:700;font-size:1rem">2</span>
</div>
<div>
<span class="rl-badge" style="background:rgba(236,72,153,0.15);color:#f472b6;margin-bottom:0.25rem">Stage 2</span>
<h3 style="margin-bottom:0;font-size:1rem">Weaving Dashboard</h3>
</div>
</div>
<p>
Drag commitments onto an SVG canvas alongside task templates. Draw wires from
commitment ports to task skill slots. The progress bar fills as skills match.
When all needs are met, the task node <strong style="color:#10b981">glows green</strong>.
</p>
</div>
<!-- Stage 3 -->
<div class="rl-card" style="border-color:rgba(16,185,129,0.2)">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
<div style="width:2.5rem;height:2.5rem;border-radius:9999px;background:linear-gradient(to bottom right,#10b981,#059669);display:flex;align-items:center;justify-content:center">
<span style="color:white;font-weight:700;font-size:1rem">3</span>
</div>
<div>
<span class="rl-badge" style="background:rgba(16,185,129,0.1);color:#10b981;margin-bottom:0.25rem">Stage 3</span>
<h3 style="margin-bottom:0;font-size:1rem">Execute & Launch</h3>
</div>
</div>
<p>
Click <strong style="color:#e2e8f0">Execute Project</strong> to open a step-by-step
launch panel: set up space, create comms channels, prep docs, and confirm roles.
Track progress as your community's woven commitments become reality.
</p>
</div>
</div>
</div>
</section>
<!-- Why Timebanking? -->
<section class="rl-section">
<div class="rl-container">
<div style="text-align:center;margin-bottom:2.5rem">
<h2 class="rl-heading" style="background:linear-gradient(135deg,#e2e8f0,#cbd5e1);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Why Community-Based Ledgers?
</h2>
<p style="color:#94a3b8;max-width:640px;margin:0 auto">
Traditional volunteering is invisible. Timebanking makes every contribution visible,
valued, and connectable.
</p>
</div>
<div class="rl-grid-2" style="max-width:900px;margin:0 auto">
<!-- The Problem -->
<div class="rl-card" style="border:2px solid rgba(239,68,68,0.2);background:rgba(239,68,68,0.04)">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:1rem">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
<h3 style="color:#f87171;margin-bottom:0;font-size:1.05rem">Without Timebanking</h3>
</div>
<ul style="list-style:disc;padding-left:1.25rem;margin:0">
<li style="font-size:0.85rem;color:#94a3b8;line-height:1.8">Volunteer hours vanish &mdash; no record, no recognition</li>
<li style="font-size:0.85rem;color:#94a3b8;line-height:1.8">Same few people carry the load while others can't find where to help</li>
<li style="font-size:0.85rem;color:#94a3b8;line-height:1.8">Projects stall because coordinators can't see available capacity</li>
<li style="font-size:0.85rem;color:#94a3b8;line-height:1.8">Skills go unmatched to needs</li>
</ul>
</div>
<!-- The Solution -->
<div class="rl-card" style="border:2px solid rgba(139,92,246,0.25);background:linear-gradient(to bottom right,rgba(139,92,246,0.05),rgba(236,72,153,0.03))">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:1rem">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<h3 style="color:#a78bfa;margin-bottom:0;font-size:1.05rem">With rTime</h3>
</div>
<ul style="list-style:disc;padding-left:1.25rem;margin:0">
<li style="font-size:0.85rem;color:#94a3b8;line-height:1.8">Every pledge is visible &mdash; orbs in a shared basket</li>
<li style="font-size:0.85rem;color:#94a3b8;line-height:1.8">Skill + hours + name = a connectable resource</li>
<li style="font-size:0.85rem;color:#94a3b8;line-height:1.8">Projects self-assemble when enough capacity converges</li>
<li style="font-size:0.85rem;color:#94a3b8;line-height:1.8">All data persists locally via CRDT &mdash; works offline</li>
</ul>
</div>
</div>
</div>
</section>
<!-- Feature Grid -->
<section class="rl-section rl-section--alt">
<div class="rl-container">
<div style="text-align:center;margin-bottom:2rem">
<h2 class="rl-heading" style="background:linear-gradient(135deg,#e2e8f0,#cbd5e1);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Built for Real Communities
</h2>
</div>
<div class="rl-grid-4">
<div class="rl-card rl-card--center" style="border-color:rgba(139,92,246,0.15)">
<div style="width:3rem;height:3rem;border-radius:9999px;background:linear-gradient(to bottom right,#8b5cf6,#7c3aed);display:flex;align-items:center;justify-content:center;margin:0 auto 0.75rem">
<span style="font-size:1.25rem">🧺</span>
</div>
<h3>Visual Pool</h3>
<p>Physics-based orbs show your community's available hours at a glance.</p>
</div>
<div class="rl-card rl-card--center" style="border-color:rgba(236,72,153,0.15)">
<div style="width:3rem;height:3rem;border-radius:9999px;background:linear-gradient(to bottom right,#ec4899,#db2777);display:flex;align-items:center;justify-content:center;margin:0 auto 0.75rem">
<span style="font-size:1.25rem">🧶</span>
</div>
<h3>Wire Editor</h3>
<p>Drag-and-drop SVG canvas with bezier connections between skills and tasks.</p>
</div>
<div class="rl-card rl-card--center" style="border-color:rgba(16,185,129,0.15)">
<div style="width:3rem;height:3rem;border-radius:9999px;background:linear-gradient(to bottom right,#10b981,#059669);display:flex;align-items:center;justify-content:center;margin:0 auto 0.75rem">
<span style="font-size:1.25rem">🔄</span>
</div>
<h3>Cyclos Ready</h3>
<p>Optional integration with Cyclos for real timebank balances and hour transfers.</p>
</div>
<div class="rl-card rl-card--center" style="border-color:rgba(59,130,246,0.15)">
<div style="width:3rem;height:3rem;border-radius:9999px;background:linear-gradient(to bottom right,#3b82f6,#2563eb);display:flex;align-items:center;justify-content:center;margin:0 auto 0.75rem">
<span style="font-size:1.25rem">📴</span>
</div>
<h3>Offline-First</h3>
<p>Automerge CRDTs keep your data local and synced. No internet required to pledge.</p>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="rl-section">
<div class="rl-container">
<div class="rl-card" style="border:2px solid rgba(139,92,246,0.25);background:linear-gradient(to bottom right,rgba(139,92,246,0.08),rgba(236,72,153,0.04));text-align:center;padding:3rem 2rem;position:relative;overflow:hidden">
<span class="rl-badge" style="background:rgba(139,92,246,0.1);color:#a78bfa;font-size:0.7rem;padding:0.25rem 0.75rem">
Join the rSpace Ecosystem
</span>
<h2 style="font-size:1.75rem;font-weight:700;color:#e2e8f0;margin:1rem 0">
Ready to weave your community's time?
</h2>
<p style="font-size:1.05rem;color:#94a3b8;max-width:560px;margin:0 auto 2rem;line-height:1.6">
Create a Space and invite members to pledge their hours. Match skills to projects
and launch coordinated community action &mdash; all on a community-based ledger
that values every hour equally.
</p>
<div class="rl-cta-row" style="margin-top:0">
<a href="/create-space" class="rl-cta-primary"
style="background:linear-gradient(to right,#8b5cf6,#ec4899);color:white">
<span style="display:inline-flex;align-items:center;gap:0.5rem">
Create a Space
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
</span>
</a>
<a href="https://demo.rspace.online/rtime" class="rl-cta-secondary">
<span style="display:inline-flex;align-items:center;gap:0.5rem">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5,3 19,12 5,21"/></svg>
Interactive Demo
</span>
</a>
</div>
</div>
</div>
</section>
<div class="rl-back">
<a href="/">&larr; Back to rSpace</a>
</div>`;
}

418
modules/rtime/mod.ts Normal file
View File

@ -0,0 +1,418 @@
/**
* 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<CommitmentsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<CommitmentsDoc>(), '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<TasksDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<TasksDoc>(), '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<string, string> {
const h: Record<string, string> = { '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<Commitment, 'id' | 'createdAt'>[] = [
{ 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<Task, 'id'>[] = [
{ 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<CommitmentsDoc>(commitmentsDocId(space));
if (existing?.meta?.seeded) return;
ensureCommitmentsDoc(space);
const now = Date.now();
_syncServer.changeDoc<CommitmentsDoc>(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<TasksDoc>(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<CommitmentsDoc>(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<CommitmentsDoc>(commitmentsDocId(space), 'add commitment', (d) => {
d.items[id] = { id, memberName, hours: Math.max(1, Math.min(10, hours)), skill, desc: desc || '', createdAt: now } as any;
});
const doc = _syncServer!.getDoc<CommitmentsDoc>(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<CommitmentsDoc>(commitmentsDocId(space))!;
if (!doc.items[id]) return c.json({ error: "Not found" }, 404);
_syncServer!.changeDoc<CommitmentsDoc>(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<TasksDoc>(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<TasksDoc>(tasksDocId(space), 'add task', (d) => {
d.tasks[id] = { id, name, description: description || '', needs, links: [], notes: '' } as any;
});
const doc = _syncServer!.getDoc<TasksDoc>(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<TasksDoc>(tasksDocId(space))!;
if (!doc.tasks[id]) return c.json({ error: "Not found" }, 404);
_syncServer!.changeDoc<TasksDoc>(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<TasksDoc>(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<TasksDoc>(tasksDocId(space), 'add connection', (d) => {
d.connections[id] = { id, fromCommitmentId, toTaskId, skill } as any;
});
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
return c.json(doc.connections[id], 201);
});
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<TasksDoc>(tasksDocId(space))!;
if (!doc.connections[id]) return c.json({ error: "Not found" }, 404);
_syncServer!.changeDoc<TasksDoc>(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<TasksDoc>(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<TasksDoc>(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: `<folk-timebank-app space="${space}" view="pool"></folk-timebank-app>`,
scripts: `<script type="module" src="/modules/rtime/folk-timebank-app.js?v=1"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtime/rtime.css">`,
}));
});
// ── 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' },
],
};

138
modules/rtime/schemas.ts Normal file
View File

@ -0,0 +1,138 @@
/**
* rTime Automerge document schemas.
*
* DocId formats:
* {space}:rtime:commitments CommitmentsDoc (commitment pool)
* {space}:rtime:tasks TasksDoc (weaving: tasks, connections, exec states)
*/
import type { DocSchema } from '../../shared/local-first/document';
// ── Skill type ──
export type Skill = 'facilitation' | 'design' | 'tech' | 'outreach' | 'logistics';
export const SKILL_COLORS: Record<Skill, string> = {
facilitation: '#8b5cf6',
design: '#ec4899',
tech: '#3b82f6',
outreach: '#10b981',
logistics: '#f59e0b',
};
export const SKILL_LABELS: Record<Skill, string> = {
facilitation: 'Facilitation',
design: 'Design',
tech: 'Tech',
outreach: 'Outreach',
logistics: 'Logistics',
};
// ── Commitment ──
export interface Commitment {
id: string;
memberName: string;
hours: number; // 110
skill: Skill;
desc: string;
cyclosMemberId?: string;
createdAt: number;
}
// ── Task / Connection / ExecState ──
export interface Task {
id: string;
name: string;
description: string;
needs: Record<string, number>; // skill → hours needed
links: { label: string; url: string }[];
notes: string;
}
export interface Connection {
id: string;
fromCommitmentId: string;
toTaskId: string;
skill: string;
}
export interface ExecState {
taskId: string;
steps: Record<string, 'pending' | 'active' | 'done'>;
launchedAt?: number;
}
// ── Documents ──
export interface CommitmentsDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
seeded?: boolean;
};
items: Record<string, Commitment>;
}
export interface TasksDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
tasks: Record<string, Task>;
connections: Record<string, Connection>;
execStates: Record<string, ExecState>;
}
// ── DocId helpers ──
export function commitmentsDocId(space: string) {
return `${space}:rtime:commitments` as const;
}
export function tasksDocId(space: string) {
return `${space}:rtime:tasks` as const;
}
// ── Schema registrations ──
export const commitmentsSchema: DocSchema<CommitmentsDoc> = {
module: 'rtime',
collection: 'commitments',
version: 1,
init: (): CommitmentsDoc => ({
meta: {
module: 'rtime',
collection: 'commitments',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
items: {},
}),
};
export const tasksSchema: DocSchema<TasksDoc> = {
module: 'rtime',
collection: 'tasks',
version: 1,
init: (): TasksDoc => ({
meta: {
module: 'rtime',
collection: 'tasks',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
tasks: {},
connections: {},
execStates: {},
}),
};

View File

@ -82,6 +82,7 @@ import { scheduleModule } from "../modules/rschedule/mod";
import { bnbModule } from "../modules/rbnb/mod";
import { vnbModule } from "../modules/rvnb/mod";
import { crowdsurfModule } from "../modules/crowdsurf/mod";
import { timeModule } from "../modules/rtime/mod";
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
import type { SpaceRoleString } from "./spaces";
import { renderShell, renderSubPageInfo, renderModuleLanding, renderOnboarding, setFragmentMode } from "./shell";
@ -126,6 +127,7 @@ registerModule(chatsModule);
registerModule(bnbModule);
registerModule(vnbModule);
registerModule(crowdsurfModule);
registerModule(timeModule);
registerModule(designModule); // Scribus DTP + AI design agent
// De-emphasized modules (bottom of menu)
registerModule(forumModule);

View File

@ -48,6 +48,7 @@ const FAVICON_BADGE_MAP: Record<string, { badge: string; color: string }> = {
crowdsurf: { badge: "r🏄", color: "#fde68a" },
rids: { badge: "r🪪", color: "#6ee7b7" },
rdesign: { badge: "r🎨", color: "#7c3aed" },
rtime: { badge: "r⏳", color: "#a78bfa" },
rstack: { badge: "r✨", color: "#c4b5fd" },
};

View File

@ -391,6 +391,33 @@ export default defineConfig({
resolve(__dirname, "dist/modules/crowdsurf/crowdsurf.css"),
);
// Build rtime module component
await wasmBuild({
configFile: false,
root: resolve(__dirname, "modules/rtime/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rtime"),
lib: {
entry: resolve(__dirname, "modules/rtime/components/folk-timebank-app.ts"),
formats: ["es"],
fileName: () => "folk-timebank-app.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-timebank-app.js",
},
},
},
});
// Copy rtime CSS
mkdirSync(resolve(__dirname, "dist/modules/rtime"), { recursive: true });
copyFileSync(
resolve(__dirname, "modules/rtime/components/rtime.css"),
resolve(__dirname, "dist/modules/rtime/rtime.css"),
);
// Build flows module components
const flowsAlias = {
"../lib/types": resolve(__dirname, "modules/rflows/lib/types.ts"),