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 <noreply@anthropic.com>
This commit is contained in:
parent
b3d6f2eba8
commit
d1a9fc338d
|
|
@ -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 {
|
|||
<div class="tab-bar">
|
||||
<div class="tab active" data-view="canvas">Canvas</div>
|
||||
<div class="tab" data-view="collaborate">Collaborate</div>
|
||||
<div class="tab" data-view="dashboard">Fulfillment</div>
|
||||
<button class="theme-toggle" id="themeToggle" title="Toggle light/dark theme">☀</button>
|
||||
</div>
|
||||
<div class="stats-bar">
|
||||
|
|
@ -533,6 +535,30 @@ class FolkTimebankApp extends HTMLElement {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dashboard-view" style="display:none">
|
||||
<div class="collab-panel">
|
||||
<div class="collab-section">
|
||||
<div class="collab-section-header">
|
||||
<h3>Fulfillment Dashboard</h3>
|
||||
</div>
|
||||
<div class="dashboard-summary" id="dashboardSummary">
|
||||
<div class="collab-empty">Loading fulfillment data...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collab-section">
|
||||
<div class="collab-section-header">
|
||||
<h3>Time Logs</h3>
|
||||
</div>
|
||||
<div class="dashboard-logs" id="dashboardLogs"></div>
|
||||
</div>
|
||||
<div class="collab-section">
|
||||
<div class="collab-section-header">
|
||||
<h3>Skill Totals</h3>
|
||||
</div>
|
||||
<div class="dashboard-skills" id="dashboardSkills"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Intent Modal -->
|
||||
|
|
@ -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 = '<div class="collab-empty">No fulfillment data available.</div>';
|
||||
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 = `
|
||||
<div style="display:flex;gap:2rem;padding:0.5rem 0">
|
||||
<div class="price-card">
|
||||
<span class="price-skill">Total Hours</span>
|
||||
<span class="price-value" style="font-size:1.4rem">${totalHours.toFixed(1)}h</span>
|
||||
</div>
|
||||
<div class="price-card">
|
||||
<span class="price-skill">Settled</span>
|
||||
<span class="price-value" style="font-size:1.4rem;color:#10b981">${settledHours.toFixed(1)}h</span>
|
||||
</div>
|
||||
<div class="price-card">
|
||||
<span class="price-skill">Pending</span>
|
||||
<span class="price-value" style="font-size:1.4rem;color:#f59e0b">${pendingCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Logs table
|
||||
if (logsEl) {
|
||||
if (logs.length === 0) {
|
||||
logsEl.innerHTML = '<div class="collab-empty">No time logs imported yet. Use <code>backlog task log --push</code> to send entries.</div>';
|
||||
} else {
|
||||
logsEl.innerHTML = `
|
||||
<table style="width:100%;border-collapse:collapse;font-size:0.85rem">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid rgba(255,255,255,0.1);text-align:left">
|
||||
<th style="padding:0.4rem">Task</th>
|
||||
<th style="padding:0.4rem">Skill</th>
|
||||
<th style="padding:0.4rem">Hours</th>
|
||||
<th style="padding:0.4rem">Status</th>
|
||||
<th style="padding:0.4rem">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${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'
|
||||
? `<button class="dashboard-settle-btn" data-log-id="${log.id}" style="padding:2px 8px;font-size:0.75rem;border:1px solid #10b981;background:transparent;color:#10b981;border-radius:4px;cursor:pointer">Settle</button>`
|
||||
: '<span style="color:#10b981">✓</span>';
|
||||
return `
|
||||
<tr style="border-bottom:1px solid rgba(255,255,255,0.05)">
|
||||
<td style="padding:0.4rem">${log.backlogTaskTitle || log.backlogTaskId}</td>
|
||||
<td style="padding:0.4rem"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${color};margin-right:4px"></span>${SKILL_LABELS[log.skill] || log.skill}</td>
|
||||
<td style="padding:0.4rem">${log.hours.toFixed(1)}h</td>
|
||||
<td style="padding:0.4rem;color:${statusColor}">${log.status}</td>
|
||||
<td style="padding:0.4rem">${settleBtn}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
// 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<string, { total: number; settled: number }>();
|
||||
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 = '<div class="collab-empty">No skill data yet.</div>';
|
||||
} 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 `
|
||||
<div style="display:flex;align-items:center;gap:0.6rem;padding:0.4rem 0">
|
||||
<div class="price-dot" style="background:${color}"></div>
|
||||
<span style="width:6rem">${SKILL_LABELS[skill] || skill}</span>
|
||||
<div style="flex:1;height:8px;background:rgba(255,255,255,0.1);border-radius:4px;overflow:hidden">
|
||||
<div style="width:${pct}%;height:100%;background:${color};border-radius:4px"></div>
|
||||
</div>
|
||||
<span style="font-size:0.8rem;color:#94a3b8;min-width:5rem;text-align:right">${data.settled.toFixed(1)}h / ${data.total.toFixed(1)}h</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (summaryEl) summaryEl.innerHTML = '<div class="collab-empty">Unable to load fulfillment data (offline?).</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── CSS ──
|
||||
|
|
|
|||
|
|
@ -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<ExternalTimeLogsDoc>(docId);
|
||||
if (!doc) {
|
||||
doc = Automerge.change(Automerge.init<ExternalTimeLogsDoc>(), '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<ExternalTimeLogsDoc>(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<CommitmentsDoc>(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<ExternalTimeLogsDoc>(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<TasksDoc>(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<TasksDoc>(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<ExternalTimeLogsDoc>(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<ExternalTimeLogsDoc>(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<ExternalTimeLogsDoc>(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<ExternalTimeLogsDoc>(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<CommitmentsDoc>(commitmentsDocId(space))!;
|
||||
if (cDoc.items[log.commitmentId]) {
|
||||
_syncServer!.changeDoc<CommitmentsDoc>(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<any>(repDocId);
|
||||
if (repDoc) {
|
||||
_syncServer!.changeDoc<any>(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<any>(scDocId);
|
||||
if (scDoc) {
|
||||
_syncServer!.changeDoc<any>(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<TasksDoc>(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<TasksDoc>(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<TasksDoc>(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<TasksDoc>(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: `<folk-timebank-app space="${space}" view="dashboard"></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 = {
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -97,6 +97,36 @@ export interface TasksDoc {
|
|||
execStates: Record<string, ExecState>;
|
||||
}
|
||||
|
||||
// ── 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<string, ExternalTimeLog>;
|
||||
}
|
||||
|
||||
// ── 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<CommitmentsDoc> = {
|
||||
|
|
@ -142,3 +176,19 @@ export const tasksSchema: DocSchema<TasksDoc> = {
|
|||
execStates: {},
|
||||
}),
|
||||
};
|
||||
|
||||
export const externalTimeLogsSchema: DocSchema<ExternalTimeLogsDoc> = {
|
||||
module: 'rtime',
|
||||
collection: 'external-time-logs',
|
||||
version: 1,
|
||||
init: (): ExternalTimeLogsDoc => ({
|
||||
meta: {
|
||||
module: 'rtime',
|
||||
collection: 'external-time-logs',
|
||||
version: 1,
|
||||
spaceSlug: '',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
logs: {},
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue