From d1a9fc338dfc6c1beb8405d4fe4ea70e21a4bacc Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 17:38:08 -0700 Subject: [PATCH] feat(rtime): add external time logs API and fulfillment dashboard Add ExternalTimeLog schema and REST endpoints for ingesting time entries from backlog-md CLI. Auto-creates commitments, links to existing tasks, and supports solo settlement with reputation/skill curve updates. New Fulfillment dashboard tab shows time logs, skill totals with progress bars, and inline settle buttons. Export-to-backlog endpoint enables importing rTime tasks into local backlog. Co-Authored-By: Claude Opus 4.6 --- modules/rtime/components/folk-timebank-app.ts | 186 ++++++++++- modules/rtime/mod.ts | 297 +++++++++++++++++- modules/rtime/schemas.ts | 50 +++ 3 files changed, 525 insertions(+), 8 deletions(-) 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 {
+ @@ -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 = ` + + + + + + + + + + + + ${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 ` + + + + + + + + `; + }).join('')} + +
TaskSkillHoursStatusAction
${log.backlogTaskTitle || log.backlogTaskId}${SKILL_LABELS[log.skill] || log.skill}${log.hours.toFixed(1)}h${log.status}${settleBtn}
+ `; + + // 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: {}, + }), +};