Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m17s
Details
CI/CD / deploy (push) Successful in 2m17s
Details
This commit is contained in:
commit
7e61d23799
|
|
@ -248,7 +248,7 @@ function svgText(txt: string, x: number, y: number, size: number, color: string,
|
||||||
class FolkTimebankApp extends HTMLElement {
|
class FolkTimebankApp extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
private space = 'demo';
|
private space = 'demo';
|
||||||
private currentView: 'canvas' | 'collaborate' = 'canvas';
|
private currentView: 'canvas' | 'collaborate' | 'dashboard' = 'canvas';
|
||||||
|
|
||||||
// Pool panel state
|
// Pool panel state
|
||||||
private canvas!: HTMLCanvasElement;
|
private canvas!: HTMLCanvasElement;
|
||||||
|
|
@ -326,7 +326,7 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
|
|
||||||
attributeChangedCallback(name: string, _old: string, val: string) {
|
attributeChangedCallback(name: string, _old: string, val: string) {
|
||||||
if (name === 'space') this.space = val;
|
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() {
|
connectedCallback() {
|
||||||
|
|
@ -335,6 +335,7 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
// Map legacy view names
|
// Map legacy view names
|
||||||
if (rawView === 'pool' || rawView === 'weave' || rawView === 'canvas') this.currentView = 'canvas';
|
if (rawView === 'pool' || rawView === 'weave' || rawView === 'canvas') this.currentView = 'canvas';
|
||||||
else if (rawView === 'collaborate') this.currentView = 'collaborate';
|
else if (rawView === 'collaborate') this.currentView = 'collaborate';
|
||||||
|
else if (rawView === 'dashboard') this.currentView = 'dashboard';
|
||||||
else this.currentView = 'canvas';
|
else this.currentView = 'canvas';
|
||||||
this.dpr = window.devicePixelRatio || 1;
|
this.dpr = window.devicePixelRatio || 1;
|
||||||
this._theme = (localStorage.getItem('rtime-theme') as 'dark' | 'light') || 'dark';
|
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-bar">
|
||||||
<div class="tab active" data-view="canvas">Canvas</div>
|
<div class="tab active" data-view="canvas">Canvas</div>
|
||||||
<div class="tab" data-view="collaborate">Collaborate</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>
|
<button class="theme-toggle" id="themeToggle" title="Toggle light/dark theme">☀</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-bar">
|
<div class="stats-bar">
|
||||||
|
|
@ -533,6 +535,30 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Create Intent Modal -->
|
<!-- Create Intent Modal -->
|
||||||
|
|
@ -654,19 +680,22 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
this.canvas = this.shadow.getElementById('pool-canvas') as HTMLCanvasElement;
|
this.canvas = this.shadow.getElementById('pool-canvas') as HTMLCanvasElement;
|
||||||
this.ctx = this.canvas.getContext('2d')!;
|
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 => {
|
this.shadow.querySelectorAll('.tab').forEach(tab => {
|
||||||
tab.addEventListener('click', () => {
|
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;
|
if (view === this.currentView) return;
|
||||||
this.currentView = view;
|
this.currentView = view;
|
||||||
this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === 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 canvasView = this.shadow.getElementById('canvas-view')!;
|
||||||
const collabView = this.shadow.getElementById('collaborate-view')!;
|
const collabView = this.shadow.getElementById('collaborate-view')!;
|
||||||
|
const dashView = this.shadow.getElementById('dashboard-view')!;
|
||||||
canvasView.style.display = view === 'canvas' ? 'flex' : 'none';
|
canvasView.style.display = view === 'canvas' ? 'flex' : 'none';
|
||||||
collabView.style.display = view === 'collaborate' ? '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 === 'canvas') { this.resizePoolCanvas(); this.rebuildSidebar(); }
|
||||||
if (view === 'collaborate') this.refreshCollaborate();
|
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.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('canvas-view')!.style.display = 'flex';
|
||||||
this.shadow.getElementById('collaborate-view')!.style.display = 'none';
|
this.shadow.getElementById('collaborate-view')!.style.display = 'none';
|
||||||
|
const dashView = this.shadow.getElementById('dashboard-view');
|
||||||
|
if (dashView) dashView.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderSkillPrices() {
|
private renderSkillPrices() {
|
||||||
|
|
@ -2563,6 +2594,153 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).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 ──
|
// ── CSS ──
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,12 @@ import { renderLanding } from "./landing";
|
||||||
import { notify } from '../../server/notification-service';
|
import { notify } from '../../server/notification-service';
|
||||||
import type { SyncServer } from '../../server/local-first/sync-server';
|
import type { SyncServer } from '../../server/local-first/sync-server';
|
||||||
import {
|
import {
|
||||||
commitmentsSchema, tasksSchema,
|
commitmentsSchema, tasksSchema, externalTimeLogsSchema,
|
||||||
commitmentsDocId, tasksDocId,
|
commitmentsDocId, tasksDocId, externalTimeLogsDocId,
|
||||||
} from './schemas';
|
} from './schemas';
|
||||||
import type {
|
import type {
|
||||||
CommitmentsDoc, TasksDoc,
|
CommitmentsDoc, TasksDoc, ExternalTimeLogsDoc,
|
||||||
Commitment, Task, Connection, ExecState, Skill,
|
Commitment, Task, Connection, ExecState, Skill, ExternalTimeLog,
|
||||||
} from './schemas';
|
} from './schemas';
|
||||||
import {
|
import {
|
||||||
intentsSchema, solverResultsSchema,
|
intentsSchema, solverResultsSchema,
|
||||||
|
|
@ -77,6 +77,243 @@ function newId(): string {
|
||||||
return crypto.randomUUID();
|
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 ──
|
// ── Cyclos proxy config ──
|
||||||
const CYCLOS_URL = process.env.CYCLOS_URL || '';
|
const CYCLOS_URL = process.env.CYCLOS_URL || '';
|
||||||
const CYCLOS_API_KEY = process.env.CYCLOS_API_KEY || '';
|
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 ──
|
// ── Module export ──
|
||||||
|
|
||||||
export const timeModule: RSpaceModule = {
|
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: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: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: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,
|
routes,
|
||||||
landingPage: renderLanding,
|
landingPage: renderLanding,
|
||||||
|
|
@ -515,6 +803,7 @@ export const timeModule: RSpaceModule = {
|
||||||
outputPaths: [
|
outputPaths: [
|
||||||
{ path: "canvas", name: "Canvas", icon: "🧺", description: "Unified commitment pool & task weaving canvas" },
|
{ path: "canvas", name: "Canvas", icon: "🧺", description: "Unified commitment pool & task weaving canvas" },
|
||||||
{ path: "collaborate", name: "Collaborate", icon: "🤝", description: "Intent-routed collaboration matching" },
|
{ path: "collaborate", name: "Collaborate", icon: "🤝", description: "Intent-routed collaboration matching" },
|
||||||
|
{ path: "dashboard", name: "Fulfillment", icon: "📊", description: "Personal commitment fulfillment tracking" },
|
||||||
],
|
],
|
||||||
onboardingActions: [
|
onboardingActions: [
|
||||||
{ label: "Pledge Hours", icon: "⏳", description: "Add a commitment to the pool", type: 'create', href: '/rtime' },
|
{ 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>;
|
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 ──
|
// ── DocId helpers ──
|
||||||
|
|
||||||
export function commitmentsDocId(space: string) {
|
export function commitmentsDocId(space: string) {
|
||||||
|
|
@ -107,6 +137,10 @@ export function tasksDocId(space: string) {
|
||||||
return `${space}:rtime:tasks` as const;
|
return `${space}:rtime:tasks` as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function externalTimeLogsDocId(space: string) {
|
||||||
|
return `${space}:rtime:external-time-logs` as const;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Schema registrations ──
|
// ── Schema registrations ──
|
||||||
|
|
||||||
export const commitmentsSchema: DocSchema<CommitmentsDoc> = {
|
export const commitmentsSchema: DocSchema<CommitmentsDoc> = {
|
||||||
|
|
@ -142,3 +176,19 @@ export const tasksSchema: DocSchema<TasksDoc> = {
|
||||||
execStates: {},
|
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