diff --git a/modules/rtime/components/folk-timebank-app.ts b/modules/rtime/components/folk-timebank-app.ts
index eab38e8..f6360cf 100644
--- a/modules/rtime/components/folk-timebank-app.ts
+++ b/modules/rtime/components/folk-timebank-app.ts
@@ -248,7 +248,7 @@ function svgText(txt: string, x: number, y: number, size: number, color: string,
class FolkTimebankApp extends HTMLElement {
private shadow: ShadowRoot;
private space = 'demo';
- private currentView: 'canvas' | 'collaborate' = 'canvas';
+ private currentView: 'canvas' | 'collaborate' | 'dashboard' = 'canvas';
// Pool panel state
private canvas!: HTMLCanvasElement;
@@ -326,7 +326,7 @@ class FolkTimebankApp extends HTMLElement {
attributeChangedCallback(name: string, _old: string, val: string) {
if (name === 'space') this.space = val;
- if (name === 'view' && (val === 'canvas' || val === 'collaborate')) this.currentView = val;
+ if (name === 'view' && (val === 'canvas' || val === 'collaborate' || val === 'dashboard')) this.currentView = val;
}
connectedCallback() {
@@ -335,6 +335,7 @@ class FolkTimebankApp extends HTMLElement {
// Map legacy view names
if (rawView === 'pool' || rawView === 'weave' || rawView === 'canvas') this.currentView = 'canvas';
else if (rawView === 'collaborate') this.currentView = 'collaborate';
+ else if (rawView === 'dashboard') this.currentView = 'dashboard';
else this.currentView = 'canvas';
this.dpr = window.devicePixelRatio || 1;
this._theme = (localStorage.getItem('rtime-theme') as 'dark' | 'light') || 'dark';
@@ -442,6 +443,7 @@ class FolkTimebankApp extends HTMLElement {
Canvas
Collaborate
+
Fulfillment
@@ -533,6 +535,30 @@ class FolkTimebankApp extends HTMLElement {
+
+
+
+
+
+
Loading fulfillment data...
+
+
+
+
+
+
@@ -654,19 +680,22 @@ class FolkTimebankApp extends HTMLElement {
this.canvas = this.shadow.getElementById('pool-canvas') as HTMLCanvasElement;
this.ctx = this.canvas.getContext('2d')!;
- // Tab switching (2 tabs: canvas, collaborate)
+ // Tab switching (3 tabs: canvas, collaborate, dashboard)
this.shadow.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
- const view = (tab as HTMLElement).dataset.view as 'canvas' | 'collaborate';
+ const view = (tab as HTMLElement).dataset.view as 'canvas' | 'collaborate' | 'dashboard';
if (view === this.currentView) return;
this.currentView = view;
this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === view));
const canvasView = this.shadow.getElementById('canvas-view')!;
const collabView = this.shadow.getElementById('collaborate-view')!;
+ const dashView = this.shadow.getElementById('dashboard-view')!;
canvasView.style.display = view === 'canvas' ? 'flex' : 'none';
collabView.style.display = view === 'collaborate' ? 'flex' : 'none';
+ dashView.style.display = view === 'dashboard' ? 'flex' : 'none';
if (view === 'canvas') { this.resizePoolCanvas(); this.rebuildSidebar(); }
if (view === 'collaborate') this.refreshCollaborate();
+ if (view === 'dashboard') this.refreshDashboard();
});
});
@@ -2544,6 +2573,8 @@ class FolkTimebankApp extends HTMLElement {
this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === 'canvas'));
this.shadow.getElementById('canvas-view')!.style.display = 'flex';
this.shadow.getElementById('collaborate-view')!.style.display = 'none';
+ const dashView = this.shadow.getElementById('dashboard-view');
+ if (dashView) dashView.style.display = 'none';
}
private renderSkillPrices() {
@@ -2563,6 +2594,153 @@ class FolkTimebankApp extends HTMLElement {
`;
}).join('');
}
+
+ // ── Fulfillment Dashboard ──
+
+ private async refreshDashboard() {
+ const base = this.getApiBase();
+ const summaryEl = this.shadow.getElementById('dashboardSummary');
+ const logsEl = this.shadow.getElementById('dashboardLogs');
+ const skillsEl = this.shadow.getElementById('dashboardSkills');
+
+ try {
+ const [logsResp, repResp] = await Promise.all([
+ fetch(`${base}/api/external-time-logs`),
+ fetch(`${base}/api/reputation/self`).catch(() => null),
+ ]);
+
+ if (!logsResp.ok) {
+ if (summaryEl) summaryEl.innerHTML = 'No fulfillment data available.
';
+ return;
+ }
+
+ const logsData = await logsResp.json();
+ const logs: Array<{
+ id: string;
+ backlogTaskId: string;
+ backlogTaskTitle: string;
+ hours: number;
+ skill: string;
+ status: string;
+ note?: string;
+ loggedAt: number;
+ commitmentId?: string;
+ }> = logsData.logs || [];
+
+ // Summary stats
+ const totalHours = logs.reduce((s, l) => s + l.hours, 0);
+ const settledHours = logs.filter(l => l.status === 'settled').reduce((s, l) => s + l.hours, 0);
+ const pendingCount = logs.filter(l => l.status !== 'settled').length;
+
+ if (summaryEl) {
+ summaryEl.innerHTML = `
+
+
+ Total Hours
+ ${totalHours.toFixed(1)}h
+
+
+ Settled
+ ${settledHours.toFixed(1)}h
+
+
+ Pending
+ ${pendingCount}
+
+
+ `;
+ }
+
+ // Logs table
+ if (logsEl) {
+ if (logs.length === 0) {
+ logsEl.innerHTML = 'No time logs imported yet. Use backlog task log --push to send entries.
';
+ } else {
+ logsEl.innerHTML = `
+
+
+
+ | Task |
+ Skill |
+ Hours |
+ Status |
+ Action |
+
+
+
+ ${logs.map(log => {
+ const color = SKILL_COLORS[log.skill] || '#8b5cf6';
+ const statusColor = log.status === 'settled' ? '#10b981' : log.status === 'commitment_created' ? '#3b82f6' : '#f59e0b';
+ const settleBtn = log.status !== 'settled'
+ ? ``
+ : '✓';
+ return `
+
+ | ${log.backlogTaskTitle || log.backlogTaskId} |
+ ${SKILL_LABELS[log.skill] || log.skill} |
+ ${log.hours.toFixed(1)}h |
+ ${log.status} |
+ ${settleBtn} |
+
+ `;
+ }).join('')}
+
+
+ `;
+
+ // Bind settle buttons
+ logsEl.querySelectorAll('.dashboard-settle-btn').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const logId = (btn as HTMLElement).dataset.logId;
+ if (!logId) return;
+ const token = localStorage.getItem('rspace_token') || '';
+ const resp = await fetch(`${base}/api/external-time-logs/${logId}/settle`, {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
+ });
+ if (resp.ok) {
+ this.refreshDashboard();
+ }
+ });
+ });
+ }
+ }
+
+ // Skill totals
+ if (skillsEl) {
+ const skillTotals = new Map();
+ for (const log of logs) {
+ const existing = skillTotals.get(log.skill) || { total: 0, settled: 0 };
+ existing.total += log.hours;
+ if (log.status === 'settled') existing.settled += log.hours;
+ skillTotals.set(log.skill, existing);
+ }
+
+ if (skillTotals.size === 0) {
+ skillsEl.innerHTML = 'No skill data yet.
';
+ } else {
+ skillsEl.innerHTML = [...skillTotals.entries()]
+ .sort((a, b) => b[1].total - a[1].total)
+ .map(([skill, data]) => {
+ const color = SKILL_COLORS[skill] || '#8b5cf6';
+ const pct = data.total > 0 ? (data.settled / data.total * 100) : 0;
+ return `
+
+
+
${SKILL_LABELS[skill] || skill}
+
+
${data.settled.toFixed(1)}h / ${data.total.toFixed(1)}h
+
+ `;
+ }).join('');
+ }
+ }
+ } catch {
+ if (summaryEl) summaryEl.innerHTML = 'Unable to load fulfillment data (offline?).
';
+ }
+ }
}
// ── CSS ──
diff --git a/modules/rtime/mod.ts b/modules/rtime/mod.ts
index f915b6e..6358cd1 100644
--- a/modules/rtime/mod.ts
+++ b/modules/rtime/mod.ts
@@ -21,12 +21,12 @@ import { renderLanding } from "./landing";
import { notify } from '../../server/notification-service';
import type { SyncServer } from '../../server/local-first/sync-server';
import {
- commitmentsSchema, tasksSchema,
- commitmentsDocId, tasksDocId,
+ commitmentsSchema, tasksSchema, externalTimeLogsSchema,
+ commitmentsDocId, tasksDocId, externalTimeLogsDocId,
} from './schemas';
import type {
- CommitmentsDoc, TasksDoc,
- Commitment, Task, Connection, ExecState, Skill,
+ CommitmentsDoc, TasksDoc, ExternalTimeLogsDoc,
+ Commitment, Task, Connection, ExecState, Skill, ExternalTimeLog,
} from './schemas';
import {
intentsSchema, solverResultsSchema,
@@ -77,6 +77,243 @@ 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 rTime task if a linked task exists
+ const tasksDocRef = _syncServer!.getDoc(tasksDocId(space));
+ if (tasksDocRef) {
+ const linkedTask = Object.values(tasksDocRef.tasks).find(
+ (t) => t.description?.includes(backlogTaskId) || t.name?.includes(backlogTaskId)
+ );
+ if (linkedTask) {
+ ensureTasksDoc(space);
+ const connId = newId();
+ _syncServer!.changeDoc(tasksDocId(space), 'auto-connect time log to task', (d) => {
+ d.connections[connId] = {
+ id: connId,
+ fromCommitmentId: commitmentId,
+ toTaskId: linkedTask.id,
+ 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]);
+});
+
// ── Cyclos proxy config ──
const CYCLOS_URL = process.env.CYCLOS_URL || '';
const CYCLOS_API_KEY = process.env.CYCLOS_API_KEY || '';
@@ -478,6 +715,56 @@ routes.get("/", (c) => {
}));
});
+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");
+ ensureTasksDoc(space);
+
+ const doc = _syncServer!.getDoc(tasksDocId(space))!;
+ const task = doc.tasks[taskId];
+ if (!task) return c.json({ error: "Task not found" }, 404);
+
+ // Calculate total needs as estimated hours
+ const estimatedHours = Object.values(task.needs).reduce((sum, h) => sum + h, 0);
+
+ // Map task needs to acceptance criteria
+ 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("/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 = {
@@ -495,6 +782,7 @@ export const timeModule: RSpaceModule = {
{ 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,
@@ -515,6 +803,7 @@ export const timeModule: RSpaceModule = {
outputPaths: [
{ path: "canvas", name: "Canvas", icon: "🧺", description: "Unified commitment pool & task weaving canvas" },
{ path: "collaborate", name: "Collaborate", icon: "🤝", description: "Intent-routed collaboration matching" },
+ { path: "dashboard", name: "Fulfillment", icon: "📊", description: "Personal commitment fulfillment tracking" },
],
onboardingActions: [
{ label: "Pledge Hours", icon: "⏳", description: "Add a commitment to the pool", type: 'create', href: '/rtime' },
diff --git a/modules/rtime/schemas.ts b/modules/rtime/schemas.ts
index 3b218a1..97d3fb9 100644
--- a/modules/rtime/schemas.ts
+++ b/modules/rtime/schemas.ts
@@ -97,6 +97,36 @@ export interface TasksDoc {
execStates: Record;
}
+// ── External Time Log (backlog-md integration) ──
+
+export type ExternalTimeLogStatus = 'pending' | 'commitment_created' | 'settled';
+
+export interface ExternalTimeLog {
+ id: string;
+ backlogTaskId: string;
+ backlogTaskTitle: string;
+ memberName: string;
+ memberId?: string; // DID
+ hours: number;
+ skill: Skill;
+ note?: string;
+ loggedAt: number; // unix ms (when work was done)
+ importedAt: number; // unix ms (when imported to rTime)
+ status: ExternalTimeLogStatus;
+ commitmentId?: string; // auto-created commitment ID
+}
+
+export interface ExternalTimeLogsDoc {
+ meta: {
+ module: string;
+ collection: string;
+ version: number;
+ spaceSlug: string;
+ createdAt: number;
+ };
+ logs: Record;
+}
+
// ── DocId helpers ──
export function commitmentsDocId(space: string) {
@@ -107,6 +137,10 @@ export function tasksDocId(space: string) {
return `${space}:rtime:tasks` as const;
}
+export function externalTimeLogsDocId(space: string) {
+ return `${space}:rtime:external-time-logs` as const;
+}
+
// ── Schema registrations ──
export const commitmentsSchema: DocSchema = {
@@ -142,3 +176,19 @@ export const tasksSchema: DocSchema = {
execStates: {},
}),
};
+
+export const externalTimeLogsSchema: DocSchema = {
+ module: 'rtime',
+ collection: 'external-time-logs',
+ version: 1,
+ init: (): ExternalTimeLogsDoc => ({
+ meta: {
+ module: 'rtime',
+ collection: 'external-time-logs',
+ version: 1,
+ spaceSlug: '',
+ createdAt: Date.now(),
+ },
+ logs: {},
+ }),
+};