Merge branch 'dev'
CI/CD / deploy (push) Has been cancelled
Details
CI/CD / deploy (push) Has been cancelled
Details
This commit is contained in:
commit
b3b5a2146b
|
|
@ -53,6 +53,18 @@ interface TaskData {
|
||||||
links: { label: string; url: string }[];
|
links: { label: string; url: string }[];
|
||||||
notes: string;
|
notes: string;
|
||||||
fulfilled?: Record<string, number>;
|
fulfilled?: Record<string, number>;
|
||||||
|
status?: string; // rTasks status (TODO, IN_PROGRESS, DONE)
|
||||||
|
canvasX?: number; // canvas position from weaving overlay
|
||||||
|
canvasY?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnplacedTask {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
description: string;
|
||||||
|
priority: string | null;
|
||||||
|
labels: string[];
|
||||||
}
|
}
|
||||||
interface WeaveNode {
|
interface WeaveNode {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -318,6 +330,7 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
// Data
|
// Data
|
||||||
private commitments: Commitment[] = [];
|
private commitments: Commitment[] = [];
|
||||||
private tasks: TaskData[] = [];
|
private tasks: TaskData[] = [];
|
||||||
|
private unplacedTasks: UnplacedTask[] = [];
|
||||||
private projectFrames: { id: string; title: string; taskIds: string[]; color?: string; x: number; y: number; w: number; h: number }[] = [];
|
private projectFrames: { id: string; title: string; taskIds: string[]; color?: string; x: number; y: number; w: number; h: number }[] = [];
|
||||||
|
|
||||||
// Collaborate state
|
// Collaborate state
|
||||||
|
|
@ -439,19 +452,32 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
private async fetchData() {
|
private async fetchData() {
|
||||||
const base = this.getApiBase();
|
const base = this.getApiBase();
|
||||||
try {
|
try {
|
||||||
const [cResp, tResp] = await Promise.all([
|
const [cResp, wResp] = await Promise.all([
|
||||||
fetch(`${base}/api/commitments`),
|
fetch(`${base}/api/commitments`),
|
||||||
fetch(`${base}/api/tasks`),
|
fetch(`${base}/api/weave`),
|
||||||
]);
|
]);
|
||||||
if (cResp.ok) {
|
if (cResp.ok) {
|
||||||
const cData = await cResp.json();
|
const cData = await cResp.json();
|
||||||
this.commitments = cData.commitments || [];
|
this.commitments = cData.commitments || [];
|
||||||
}
|
}
|
||||||
if (tResp.ok) {
|
if (wResp.ok) {
|
||||||
const tData = await tResp.json();
|
const wData = await wResp.json();
|
||||||
this.tasks = tData.tasks || [];
|
// Map placed tasks to TaskData format
|
||||||
// Restore connections (with hours + status)
|
this.tasks = (wData.placedTasks || []).map((t: any) => ({
|
||||||
this._restoredConnections = (tData.connections || []).map((cn: any) => ({
|
id: t.id,
|
||||||
|
name: t.title,
|
||||||
|
description: t.description || '',
|
||||||
|
needs: t.needs || {},
|
||||||
|
links: t.links || [],
|
||||||
|
notes: t.notes || '',
|
||||||
|
status: t.status,
|
||||||
|
canvasX: t.canvasX,
|
||||||
|
canvasY: t.canvasY,
|
||||||
|
}));
|
||||||
|
// Store unplaced tasks for picker
|
||||||
|
this.unplacedTasks = wData.unplacedTasks || [];
|
||||||
|
// Restore connections
|
||||||
|
this._restoredConnections = (wData.connections || []).map((cn: any) => ({
|
||||||
id: cn.id,
|
id: cn.id,
|
||||||
fromCommitmentId: cn.fromCommitmentId,
|
fromCommitmentId: cn.fromCommitmentId,
|
||||||
toTaskId: cn.toTaskId,
|
toTaskId: cn.toTaskId,
|
||||||
|
|
@ -460,7 +486,7 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
status: cn.status || 'proposed',
|
status: cn.status || 'proposed',
|
||||||
}));
|
}));
|
||||||
// Restore exec states
|
// Restore exec states
|
||||||
for (const es of (tData.execStates || [])) {
|
for (const es of (wData.execStates || [])) {
|
||||||
if (es.taskId && es.steps) {
|
if (es.taskId && es.steps) {
|
||||||
this.execStepStates[es.taskId] = {};
|
this.execStepStates[es.taskId] = {};
|
||||||
for (const [k, v] of Object.entries(es.steps)) {
|
for (const [k, v] of Object.entries(es.steps)) {
|
||||||
|
|
@ -487,13 +513,13 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
this.rebuildSidebar();
|
this.rebuildSidebar();
|
||||||
this.applyRestoredConnections();
|
this.applyRestoredConnections();
|
||||||
|
|
||||||
// Auto-place first task if canvas is empty
|
// Place all tasks using stored canvas positions
|
||||||
if (this.weaveNodes.length === 0 && this.tasks.length > 0) {
|
if (this.weaveNodes.length === 0 && this.tasks.length > 0) {
|
||||||
const wrap = this.shadow.getElementById('canvasWrap');
|
for (const t of this.tasks) {
|
||||||
const wrapRect = wrap?.getBoundingClientRect();
|
const x = t.canvasX ?? 400;
|
||||||
const x = wrapRect ? wrapRect.width * 0.4 : 400;
|
const y = t.canvasY ?? 150;
|
||||||
const y = wrapRect ? wrapRect.height * 0.2 : 80;
|
this.weaveNodes.push(this.mkTaskNode(t, x, y));
|
||||||
this.weaveNodes.push(this.mkTaskNode(this.tasks[0], x - TASK_W / 2, y));
|
}
|
||||||
this.renderAll();
|
this.renderAll();
|
||||||
this.rebuildSidebar();
|
this.rebuildSidebar();
|
||||||
}
|
}
|
||||||
|
|
@ -536,7 +562,7 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
<button class="pool-detail-drag-btn" id="detailDragBtn">Drag to Weave \u2192</button>
|
<button class="pool-detail-drag-btn" id="detailDragBtn">Drag to Weave \u2192</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="pool-panel-sidebar" id="poolSidebar">
|
<div class="pool-panel-sidebar" id="poolSidebar">
|
||||||
<div class="sidebar-section">Task Templates</div>
|
<div class="sidebar-section">Woven Tasks</div>
|
||||||
<div id="sidebarTasks"></div>
|
<div id="sidebarTasks"></div>
|
||||||
</div>
|
</div>
|
||||||
<button class="add-btn" id="addBtn">+ Pledge Time</button>
|
<button class="add-btn" id="addBtn">+ Pledge Time</button>
|
||||||
|
|
@ -1705,7 +1731,14 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
hm.setAttribute('width', String(node.w)); hm.setAttribute('height', '10'); hm.setAttribute('y', '20'); hm.setAttribute('fill', hCol);
|
hm.setAttribute('width', String(node.w)); hm.setAttribute('height', '10'); hm.setAttribute('y', '20'); hm.setAttribute('fill', hCol);
|
||||||
g.appendChild(hm);
|
g.appendChild(hm);
|
||||||
|
|
||||||
g.appendChild(svgText(t.name, 12, 18, 12, '#fff', '600'));
|
const nameLabel = t.name.length > 18 ? t.name.slice(0, 17) + '\u2026' : t.name;
|
||||||
|
g.appendChild(svgText(nameLabel, 12, 18, 12, '#fff', '600'));
|
||||||
|
// rTasks status badge
|
||||||
|
if (t.status && t.status !== 'TODO') {
|
||||||
|
const badge = t.status === 'DONE' ? '\u2713' : '\u25B6';
|
||||||
|
const badgeColor = t.status === 'DONE' ? '#10b981' : '#f59e0b';
|
||||||
|
g.appendChild(svgText(badge, node.w - 26, 18, 10, badgeColor, '700', 'middle'));
|
||||||
|
}
|
||||||
const editPencil = svgText('\u270E', node.w - 10, 18, 11, '#ffffff66', '400', 'middle');
|
const editPencil = svgText('\u270E', node.w - 10, 18, 11, '#ffffff66', '400', 'middle');
|
||||||
editPencil.style.pointerEvents = 'none';
|
editPencil.style.pointerEvents = 'none';
|
||||||
g.appendChild(editPencil);
|
g.appendChild(editPencil);
|
||||||
|
|
@ -2121,16 +2154,27 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist canvas position to weaving overlay
|
||||||
|
if (this.dragNode?.type === 'task') {
|
||||||
|
fetch(`${this.getApiBase()}/api/weave/overlay/${this.dragNode.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
||||||
|
body: JSON.stringify({ canvasX: this.dragNode.x, canvasY: this.dragNode.y }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
this.dragNode = null;
|
this.dragNode = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sidebar (pool panel) ──
|
// ── Sidebar (pool panel) ──
|
||||||
|
|
||||||
private rebuildSidebar() {
|
private rebuildSidebar() {
|
||||||
// Task templates in pool panel
|
|
||||||
const tc = this.shadow.getElementById('sidebarTasks');
|
const tc = this.shadow.getElementById('sidebarTasks');
|
||||||
if (!tc) return;
|
if (!tc) return;
|
||||||
tc.innerHTML = '';
|
tc.innerHTML = '';
|
||||||
|
|
||||||
|
// Show placed tasks not yet on canvas SVG
|
||||||
const usedT = new Set(this.weaveNodes.filter(n => n.type === 'task').map(n => n.id));
|
const usedT = new Set(this.weaveNodes.filter(n => n.type === 'task').map(n => n.id));
|
||||||
this.tasks.forEach(t => {
|
this.tasks.forEach(t => {
|
||||||
if (usedT.has(t.id)) return;
|
if (usedT.has(t.id)) return;
|
||||||
|
|
@ -2143,6 +2187,65 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
el.addEventListener('pointerdown', (e) => this.startSidebarDrag(e, { type: 'task', id: t.id }, t.name));
|
el.addEventListener('pointerdown', (e) => this.startSidebarDrag(e, { type: 'task', id: t.id }, t.name));
|
||||||
tc.appendChild(el);
|
tc.appendChild(el);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show unplaced rTasks items (from board)
|
||||||
|
if (this.unplacedTasks.length > 0) {
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'sidebar-section';
|
||||||
|
header.textContent = 'rTasks Board';
|
||||||
|
tc.appendChild(header);
|
||||||
|
|
||||||
|
this.unplacedTasks.forEach(ut => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'sidebar-task';
|
||||||
|
const badge = ut.status === 'DONE' ? ' \u2713' : ut.status === 'IN_PROGRESS' ? ' \u25B6' : '';
|
||||||
|
el.innerHTML = '<div class="sidebar-task-icon" style="opacity:0.6">\u25CB</div>' +
|
||||||
|
'<div class="sidebar-item-info"><div class="sidebar-item-name">' + ut.title + badge + '</div>' +
|
||||||
|
'<div class="sidebar-item-meta">' + ut.status + '</div></div>';
|
||||||
|
el.addEventListener('click', () => this.placeUnplacedTask(ut));
|
||||||
|
tc.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async placeUnplacedTask(ut: UnplacedTask) {
|
||||||
|
// Prompt for needs via simple dialog
|
||||||
|
const needsStr = prompt(`Skill needs for "${ut.title}" (e.g. tech:4, design:2):`, '');
|
||||||
|
if (needsStr === null) return;
|
||||||
|
const needs: Record<string, number> = {};
|
||||||
|
if (needsStr.trim()) {
|
||||||
|
for (const part of needsStr.split(',')) {
|
||||||
|
const [skill, hrs] = part.trim().split(':');
|
||||||
|
if (skill && hrs) needs[skill.trim()] = parseInt(hrs.trim()) || 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${this.getApiBase()}/api/weave/place/${ut.id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
||||||
|
body: JSON.stringify({ needs, canvasX: 400, canvasY: 150 }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return;
|
||||||
|
|
||||||
|
// Add to local tasks and canvas
|
||||||
|
const taskData: TaskData = {
|
||||||
|
id: ut.id,
|
||||||
|
name: ut.title,
|
||||||
|
description: ut.description || '',
|
||||||
|
needs,
|
||||||
|
links: [],
|
||||||
|
notes: '',
|
||||||
|
status: ut.status,
|
||||||
|
canvasX: 400,
|
||||||
|
canvasY: 150,
|
||||||
|
};
|
||||||
|
this.tasks.push(taskData);
|
||||||
|
this.unplacedTasks = this.unplacedTasks.filter(t => t.id !== ut.id);
|
||||||
|
this.weaveNodes.push(this.mkTaskNode(taskData, 400, 150));
|
||||||
|
this.renderAll();
|
||||||
|
this.rebuildSidebar();
|
||||||
|
} catch { /* offline */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private startSidebarDrag(e: PointerEvent, data: { type: string; id: string }, label: string) {
|
private startSidebarDrag(e: PointerEvent, data: { type: string; id: string }, label: string) {
|
||||||
|
|
@ -2619,7 +2722,7 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
const states = this.execStepStates[taskId];
|
const states = this.execStepStates[taskId];
|
||||||
if (!states) return;
|
if (!states) return;
|
||||||
try {
|
try {
|
||||||
await fetch(`${this.getApiBase()}/api/tasks/${taskId}/exec-state`, {
|
await fetch(`${this.getApiBase()}/api/weave/exec-state/${taskId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
||||||
body: JSON.stringify({ steps: states }),
|
body: JSON.stringify({ steps: states }),
|
||||||
|
|
@ -2909,12 +3012,21 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
t.description = (this.shadow.getElementById('taskEditDesc') as HTMLTextAreaElement).value.trim();
|
t.description = (this.shadow.getElementById('taskEditDesc') as HTMLTextAreaElement).value.trim();
|
||||||
t.notes = (this.shadow.getElementById('taskEditNotes') as HTMLTextAreaElement).value.trim();
|
t.notes = (this.shadow.getElementById('taskEditNotes') as HTMLTextAreaElement).value.trim();
|
||||||
t.links = this.getTaskEditLinks();
|
t.links = this.getTaskEditLinks();
|
||||||
// Persist to server
|
|
||||||
|
// Persist to server — uses compat PUT that splits rTasks title/desc + weaving overlay
|
||||||
fetch(`${this.getApiBase()}/api/tasks/${t.id}`, {
|
fetch(`${this.getApiBase()}/api/tasks/${t.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
||||||
body: JSON.stringify({ name: t.name, description: t.description, notes: t.notes, links: t.links }),
|
body: JSON.stringify({ name: t.name, description: t.description, notes: t.notes, links: t.links }),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Also persist position via overlay update
|
||||||
|
fetch(`${this.getApiBase()}/api/weave/overlay/${t.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
||||||
|
body: JSON.stringify({ notes: t.notes, links: t.links }),
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
this.renderAll();
|
this.renderAll();
|
||||||
this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible');
|
this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible');
|
||||||
this.editingTaskNode = null;
|
this.editingTaskNode = null;
|
||||||
|
|
@ -3189,34 +3301,61 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
const skillPairs = skills.length > 0 ? skills : ['facilitation'];
|
const skillPairs = skills.length > 0 ? skills : ['facilitation'];
|
||||||
|
|
||||||
// Group skills into task(s) — one task per 2 skills, or one per unique group
|
// Group skills into task(s) — one task per 2 skills, or one per unique group
|
||||||
const taskDefs: { name: string; needs: Record<string, number> }[] = [];
|
const taskDefs: { title: string; needs: Record<string, number> }[] = [];
|
||||||
if (skillPairs.length <= 2) {
|
if (skillPairs.length <= 2) {
|
||||||
taskDefs.push({
|
taskDefs.push({
|
||||||
name: `Collaboration: ${skillPairs.map((s: string) => SKILL_LABELS[s] || s).join(' + ')}`,
|
title: `Collaboration: ${skillPairs.map((s: string) => SKILL_LABELS[s] || s).join(' + ')}`,
|
||||||
needs: Object.fromEntries(skillPairs.map((s: string) => [s, Math.ceil((result.totalHours || 4) / skillPairs.length)])),
|
needs: Object.fromEntries(skillPairs.map((s: string) => [s, Math.ceil((result.totalHours || 4) / skillPairs.length)])),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
for (let i = 0; i < skillPairs.length; i += 2) {
|
for (let i = 0; i < skillPairs.length; i += 2) {
|
||||||
const group = skillPairs.slice(i, i + 2);
|
const group = skillPairs.slice(i, i + 2);
|
||||||
taskDefs.push({
|
taskDefs.push({
|
||||||
name: `${group.map((s: string) => SKILL_LABELS[s] || s).join(' + ')} Work`,
|
title: `${group.map((s: string) => SKILL_LABELS[s] || s).join(' + ')} Work`,
|
||||||
needs: Object.fromEntries(group.map((s: string) => [s, Math.ceil((result.totalHours || 4) / skillPairs.length)])),
|
needs: Object.fromEntries(group.map((s: string) => [s, Math.ceil((result.totalHours || 4) / skillPairs.length)])),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST tasks to server and collect created tasks
|
// POST tasks via create-and-place to create rTasks item + weaving overlay atomically
|
||||||
|
const wrap = this.shadow.getElementById('canvasWrap');
|
||||||
|
const wrapRect = wrap?.getBoundingClientRect();
|
||||||
|
const startX = wrapRect ? wrapRect.width * 0.15 : 100;
|
||||||
|
const startY = wrapRect ? wrapRect.height * 0.3 : 150;
|
||||||
|
const gap = TASK_W + 40;
|
||||||
|
|
||||||
const createdTasks: TaskData[] = [];
|
const createdTasks: TaskData[] = [];
|
||||||
for (const def of taskDefs) {
|
for (let i = 0; i < taskDefs.length; i++) {
|
||||||
|
const def = taskDefs[i];
|
||||||
|
const canvasX = startX + i * gap;
|
||||||
|
const canvasY = startY;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${this.getApiBase()}/api/tasks`, {
|
const resp = await fetch(`${this.getApiBase()}/api/weave/create-and-place`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
||||||
body: JSON.stringify({ name: def.name, description: `From solver result ${result.id}`, needs: def.needs, intentFrameId: result.id }),
|
body: JSON.stringify({
|
||||||
|
title: def.title,
|
||||||
|
description: `From solver result ${result.id}`,
|
||||||
|
needs: def.needs,
|
||||||
|
canvasX,
|
||||||
|
canvasY,
|
||||||
|
intentFrameId: result.id,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const task = await resp.json();
|
const data = await resp.json();
|
||||||
createdTasks.push({ ...task, fulfilled: {} });
|
const task: TaskData = {
|
||||||
|
id: data.taskId,
|
||||||
|
name: def.title,
|
||||||
|
description: `From solver result ${result.id}`,
|
||||||
|
needs: def.needs,
|
||||||
|
links: [],
|
||||||
|
notes: '',
|
||||||
|
fulfilled: {},
|
||||||
|
canvasX,
|
||||||
|
canvasY,
|
||||||
|
};
|
||||||
|
createdTasks.push(task);
|
||||||
this.tasks.push(task);
|
this.tasks.push(task);
|
||||||
}
|
}
|
||||||
} catch { /* offline */ }
|
} catch { /* offline */ }
|
||||||
|
|
@ -3224,17 +3363,9 @@ class FolkTimebankApp extends HTMLElement {
|
||||||
|
|
||||||
if (createdTasks.length === 0) return;
|
if (createdTasks.length === 0) return;
|
||||||
|
|
||||||
// Auto-layout: horizontal row on canvas
|
// Place task nodes on canvas using their stored positions
|
||||||
const wrap = this.shadow.getElementById('canvasWrap');
|
createdTasks.forEach((t) => {
|
||||||
const wrapRect = wrap?.getBoundingClientRect();
|
this.weaveNodes.push(this.mkTaskNode(t, t.canvasX ?? 400, t.canvasY ?? 150));
|
||||||
const startX = wrapRect ? wrapRect.width * 0.15 : 100;
|
|
||||||
const startY = wrapRect ? wrapRect.height * 0.3 : 150;
|
|
||||||
const gap = TASK_W + 40;
|
|
||||||
|
|
||||||
createdTasks.forEach((t, i) => {
|
|
||||||
const x = startX + i * gap;
|
|
||||||
const y = startY;
|
|
||||||
this.weaveNodes.push(this.mkTaskNode(t, x, y));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Draw dashed frame around the group in intentFramesLayer
|
// Draw dashed frame around the group in intentFramesLayer
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,17 @@ 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, externalTimeLogsSchema,
|
commitmentsSchema, tasksSchema, weavingSchema, externalTimeLogsSchema,
|
||||||
commitmentsDocId, tasksDocId, externalTimeLogsDocId,
|
commitmentsDocId, tasksDocId, weavingDocId, externalTimeLogsDocId,
|
||||||
SKILL_LABELS,
|
SKILL_LABELS,
|
||||||
} from './schemas';
|
} from './schemas';
|
||||||
import type {
|
import type {
|
||||||
CommitmentsDoc, TasksDoc, ExternalTimeLogsDoc,
|
CommitmentsDoc, TasksDoc, WeavingDoc, ExternalTimeLogsDoc,
|
||||||
Commitment, Task, Connection, ExecState, Skill, ExternalTimeLog,
|
Commitment, Task, Connection, ExecState, Skill, ExternalTimeLog,
|
||||||
|
WeavingOverlay,
|
||||||
} from './schemas';
|
} from './schemas';
|
||||||
|
import { boardDocId, createTaskItem } from '../rtasks/schemas';
|
||||||
|
import type { BoardDoc, TaskItem } from '../rtasks/schemas';
|
||||||
import {
|
import {
|
||||||
intentsSchema, solverResultsSchema,
|
intentsSchema, solverResultsSchema,
|
||||||
skillCurvesSchema, reputationSchema,
|
skillCurvesSchema, reputationSchema,
|
||||||
|
|
@ -77,6 +80,20 @@ function ensureTasksDoc(space: string): TasksDoc {
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureWeavingDoc(space: string): WeavingDoc {
|
||||||
|
const docId = weavingDocId(space);
|
||||||
|
let doc = _syncServer!.getDoc<WeavingDoc>(docId);
|
||||||
|
if (!doc) {
|
||||||
|
doc = Automerge.change(Automerge.init<WeavingDoc>(), 'init weaving', (d) => {
|
||||||
|
const init = weavingSchema.init();
|
||||||
|
Object.assign(d, init);
|
||||||
|
d.meta.spaceSlug = space;
|
||||||
|
});
|
||||||
|
_syncServer!.setDoc(docId, doc);
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
function newId(): string {
|
function newId(): string {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|
@ -165,20 +182,24 @@ routes.post("/api/external-time-logs", async (c) => {
|
||||||
d.logs[id].status = 'commitment_created' as any;
|
d.logs[id].status = 'commitment_created' as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-connect to rTime task if a linked task exists
|
// Auto-connect to weaving task if a linked task exists on canvas
|
||||||
const tasksDocRef = _syncServer!.getDoc<TasksDoc>(tasksDocId(space));
|
ensureWeavingDoc(space);
|
||||||
if (tasksDocRef) {
|
const wDoc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space));
|
||||||
const linkedTask = Object.values(tasksDocRef.tasks).find(
|
const board = getWeavingBoard(space);
|
||||||
(t) => t.description?.includes(backlogTaskId) || t.name?.includes(backlogTaskId)
|
if (wDoc && board) {
|
||||||
);
|
// Find placed task whose rTasks title/desc contains backlogTaskId
|
||||||
|
const placedIds = Object.keys(wDoc.weavingOverlays);
|
||||||
|
const linkedTask = placedIds.find(tid => {
|
||||||
|
const item = board.doc.tasks[tid];
|
||||||
|
return item && (item.description?.includes(backlogTaskId) || item.title?.includes(backlogTaskId));
|
||||||
|
});
|
||||||
if (linkedTask) {
|
if (linkedTask) {
|
||||||
ensureTasksDoc(space);
|
|
||||||
const connId = newId();
|
const connId = newId();
|
||||||
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'auto-connect time log to task', (d) => {
|
_syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'auto-connect time log to task', (d) => {
|
||||||
d.connections[connId] = {
|
d.connections[connId] = {
|
||||||
id: connId,
|
id: connId,
|
||||||
fromCommitmentId: commitmentId,
|
fromCommitmentId: commitmentId,
|
||||||
toTaskId: linkedTask.id,
|
toTaskId: linkedTask,
|
||||||
skill,
|
skill,
|
||||||
hours,
|
hours,
|
||||||
status: 'committed',
|
status: 'committed',
|
||||||
|
|
@ -318,6 +339,243 @@ routes.post("/api/tasks/:id/link-backlog", async (c) => {
|
||||||
return c.json(updated.tasks[taskId]);
|
return c.json(updated.tasks[taskId]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Weave API (rTasks integration) ──
|
||||||
|
|
||||||
|
/** Helper: find the rTasks board doc for a space's weaving canvas. */
|
||||||
|
function getWeavingBoard(space: string): { docId: string; doc: BoardDoc } | null {
|
||||||
|
const wDoc = ensureWeavingDoc(space);
|
||||||
|
const slug = wDoc.boardSlug || space;
|
||||||
|
const docId = boardDocId(space, slug);
|
||||||
|
const doc = _syncServer!.getDoc<BoardDoc>(docId);
|
||||||
|
if (!doc) return null;
|
||||||
|
return { docId, doc };
|
||||||
|
}
|
||||||
|
|
||||||
|
routes.get("/api/weave", async (c) => {
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
|
||||||
|
let callerRole: SpaceRoleString = 'viewer';
|
||||||
|
const token = extractToken(c.req.raw.headers);
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const claims = await verifyToken(token);
|
||||||
|
const resolved = await resolveCallerRole(space, claims);
|
||||||
|
if (resolved) callerRole = resolved.role;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureWeavingDoc(space);
|
||||||
|
const wDoc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
|
||||||
|
const board = getWeavingBoard(space);
|
||||||
|
|
||||||
|
const allTasks: Record<string, TaskItem> = board?.doc.tasks || {};
|
||||||
|
const placedIds = new Set(Object.keys(wDoc.weavingOverlays));
|
||||||
|
|
||||||
|
const placedTasks = Object.values(allTasks)
|
||||||
|
.filter(t => placedIds.has(t.id))
|
||||||
|
.map(t => {
|
||||||
|
const ov = wDoc.weavingOverlays[t.id];
|
||||||
|
return {
|
||||||
|
id: t.id, title: t.title, status: t.status, description: t.description,
|
||||||
|
priority: t.priority, labels: t.labels, assigneeId: t.assigneeId, dueDate: t.dueDate,
|
||||||
|
needs: ov.needs, canvasX: ov.canvasX, canvasY: ov.canvasY,
|
||||||
|
notes: ov.notes, links: ov.links, intentFrameId: ov.intentFrameId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const unplacedTasks = filterArrayByVisibility(
|
||||||
|
Object.values(allTasks).filter(t => !placedIds.has(t.id)),
|
||||||
|
callerRole,
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
boardSlug: wDoc.boardSlug || space,
|
||||||
|
placedTasks,
|
||||||
|
unplacedTasks: unplacedTasks.map(t => ({
|
||||||
|
id: t.id, title: t.title, status: t.status, description: t.description,
|
||||||
|
priority: t.priority, labels: t.labels, assigneeId: t.assigneeId, dueDate: t.dueDate,
|
||||||
|
})),
|
||||||
|
connections: Object.values(wDoc.connections),
|
||||||
|
execStates: Object.values(wDoc.execStates),
|
||||||
|
projectFrames: Object.values(wDoc.projectFrames),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.post("/api/weave/bind-board", 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 { boardSlug } = await c.req.json();
|
||||||
|
if (!boardSlug) return c.json({ error: "boardSlug required" }, 400);
|
||||||
|
|
||||||
|
// Verify board exists
|
||||||
|
const docId = boardDocId(space, boardSlug);
|
||||||
|
if (!_syncServer!.getDoc<BoardDoc>(docId)) {
|
||||||
|
return c.json({ error: "Board not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureWeavingDoc(space);
|
||||||
|
_syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'bind board', (d) => {
|
||||||
|
d.boardSlug = boardSlug;
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ ok: true, boardSlug });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.post("/api/weave/place/:rtasksId", 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 rtasksId = c.req.param("rtasksId");
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { needs, canvasX, canvasY, notes, links } = body;
|
||||||
|
|
||||||
|
// Verify task exists in rTasks board
|
||||||
|
const board = getWeavingBoard(space);
|
||||||
|
if (!board || !board.doc.tasks[rtasksId]) {
|
||||||
|
return c.json({ error: "Task not found in rTasks board" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureWeavingDoc(space);
|
||||||
|
_syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'place task on canvas', (d) => {
|
||||||
|
d.weavingOverlays[rtasksId] = {
|
||||||
|
rtasksId,
|
||||||
|
needs: needs || {},
|
||||||
|
canvasX: canvasX ?? 400,
|
||||||
|
canvasY: canvasY ?? 150,
|
||||||
|
notes: notes || '',
|
||||||
|
links: links || [],
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
const wDoc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
|
||||||
|
return c.json(wDoc.weavingOverlays[rtasksId], 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.delete("/api/weave/place/:rtasksId", 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 rtasksId = c.req.param("rtasksId");
|
||||||
|
|
||||||
|
ensureWeavingDoc(space);
|
||||||
|
const wDoc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
|
||||||
|
if (!wDoc.weavingOverlays[rtasksId]) return c.json({ error: "Not placed" }, 404);
|
||||||
|
|
||||||
|
_syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'remove from canvas', (d) => {
|
||||||
|
delete d.weavingOverlays[rtasksId];
|
||||||
|
// Remove associated connections
|
||||||
|
for (const [connId, conn] of Object.entries(d.connections)) {
|
||||||
|
if (conn.toTaskId === rtasksId) delete d.connections[connId];
|
||||||
|
}
|
||||||
|
// Remove exec state
|
||||||
|
delete d.execStates[rtasksId];
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.put("/api/weave/overlay/:rtasksId", 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 rtasksId = c.req.param("rtasksId");
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
ensureWeavingDoc(space);
|
||||||
|
const wDoc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
|
||||||
|
if (!wDoc.weavingOverlays[rtasksId]) return c.json({ error: "Not placed" }, 404);
|
||||||
|
|
||||||
|
_syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'update overlay', (d) => {
|
||||||
|
const ov = d.weavingOverlays[rtasksId];
|
||||||
|
if (body.needs !== undefined) ov.needs = body.needs;
|
||||||
|
if (body.canvasX !== undefined) ov.canvasX = body.canvasX;
|
||||||
|
if (body.canvasY !== undefined) ov.canvasY = body.canvasY;
|
||||||
|
if (body.notes !== undefined) ov.notes = body.notes;
|
||||||
|
if (body.links !== undefined) ov.links = body.links;
|
||||||
|
if (body.intentFrameId !== undefined) ov.intentFrameId = body.intentFrameId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
|
||||||
|
return c.json(updated.weavingOverlays[rtasksId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.post("/api/weave/create-and-place", 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 { title, description, needs, canvasX, canvasY, intentFrameId } = body;
|
||||||
|
if (!title) return c.json({ error: "title required" }, 400);
|
||||||
|
|
||||||
|
// Find or create the board
|
||||||
|
const board = getWeavingBoard(space);
|
||||||
|
if (!board) return c.json({ error: "No rTasks board bound" }, 404);
|
||||||
|
|
||||||
|
// Create TaskItem in rTasks
|
||||||
|
const taskId = newId();
|
||||||
|
const taskItem = createTaskItem(taskId, space, title, {
|
||||||
|
description: description || '',
|
||||||
|
createdBy: (claims.did as string) || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
_syncServer!.changeDoc<BoardDoc>(board.docId, 'create task from weave', (d) => {
|
||||||
|
d.tasks[taskId] = taskItem as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create WeavingOverlay
|
||||||
|
ensureWeavingDoc(space);
|
||||||
|
_syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'place new task on canvas', (d) => {
|
||||||
|
d.weavingOverlays[taskId] = {
|
||||||
|
rtasksId: taskId,
|
||||||
|
needs: needs || {},
|
||||||
|
canvasX: canvasX ?? 400,
|
||||||
|
canvasY: canvasY ?? 150,
|
||||||
|
notes: '',
|
||||||
|
links: [],
|
||||||
|
intentFrameId,
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ taskId, title, needs: needs || {} }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Exec State API (updated: write to WeavingDoc) ──
|
||||||
|
|
||||||
|
routes.put("/api/weave/exec-state/:taskId", 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("taskId");
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { steps, launchedAt } = body;
|
||||||
|
ensureWeavingDoc(space);
|
||||||
|
|
||||||
|
_syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'update exec state', (d) => {
|
||||||
|
if (!d.execStates[taskId]) {
|
||||||
|
d.execStates[taskId] = { taskId, steps: {}, launchedAt: undefined } as any;
|
||||||
|
}
|
||||||
|
if (steps) d.execStates[taskId].steps = steps;
|
||||||
|
if (launchedAt) d.execStates[taskId].launchedAt = launchedAt;
|
||||||
|
});
|
||||||
|
|
||||||
|
const doc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
|
||||||
|
return c.json(doc.execStates[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 || '';
|
||||||
|
|
@ -341,9 +599,9 @@ const DEMO_COMMITMENTS: Omit<Commitment, 'id' | 'createdAt'>[] = [
|
||||||
{ memberName: 'Casey Morgan', hours: 2, skill: 'logistics', desc: 'Supply procurement and transport' },
|
{ memberName: 'Casey Morgan', hours: 2, skill: 'logistics', desc: 'Supply procurement and transport' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEMO_TASKS: Omit<Task, 'id'>[] = [
|
const DEMO_SEED_TASKS: { title: string; needs: Record<string, number> }[] = [
|
||||||
{ name: 'Organize Community Event', description: '', needs: { facilitation: 3, design: 2, outreach: 2, logistics: 2 }, links: [], notes: '' },
|
{ title: 'Organize Community Event', needs: { facilitation: 3, design: 2, outreach: 2, logistics: 2 } },
|
||||||
{ name: 'Run Harm Reduction Workshop', description: '', needs: { facilitation: 4, design: 2, tech: 4, logistics: 1 }, links: [], notes: '' },
|
{ title: 'Run Harm Reduction Workshop', needs: { facilitation: 4, design: 2, tech: 4, logistics: 1 } },
|
||||||
];
|
];
|
||||||
|
|
||||||
function seedDemoIfEmpty(space: string = 'demo') {
|
function seedDemoIfEmpty(space: string = 'demo') {
|
||||||
|
|
@ -362,15 +620,41 @@ function seedDemoIfEmpty(space: string = 'demo') {
|
||||||
(d.meta as any).seeded = true;
|
(d.meta as any).seeded = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
ensureTasksDoc(space);
|
// Seed tasks into rTasks board + place on weaving canvas
|
||||||
_syncServer.changeDoc<TasksDoc>(tasksDocId(space), 'seed tasks', (d) => {
|
const board = getWeavingBoard(space);
|
||||||
for (const t of DEMO_TASKS) {
|
if (board) {
|
||||||
|
ensureWeavingDoc(space);
|
||||||
|
let canvasX = 300;
|
||||||
|
for (const t of DEMO_SEED_TASKS) {
|
||||||
const id = newId();
|
const id = newId();
|
||||||
d.tasks[id] = { id, ...t } as any;
|
const taskItem = createTaskItem(id, space, t.title, { description: '' });
|
||||||
|
_syncServer.changeDoc<BoardDoc>(board.docId, 'seed rtime task', (d) => {
|
||||||
|
d.tasks[id] = taskItem as any;
|
||||||
|
});
|
||||||
|
_syncServer.changeDoc<WeavingDoc>(weavingDocId(space), 'seed weaving overlay', (d) => {
|
||||||
|
d.weavingOverlays[id] = {
|
||||||
|
rtasksId: id,
|
||||||
|
needs: t.needs,
|
||||||
|
canvasX,
|
||||||
|
canvasY: 150,
|
||||||
|
notes: '',
|
||||||
|
links: [],
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
canvasX += 280;
|
||||||
}
|
}
|
||||||
});
|
console.log(`[rTime] Demo seeded for "${space}": ${DEMO_COMMITMENTS.length} commitments, ${DEMO_SEED_TASKS.length} tasks (rTasks + weaving)`);
|
||||||
|
} else {
|
||||||
console.log(`[rTime] Demo data seeded for "${space}": ${DEMO_COMMITMENTS.length} commitments, ${DEMO_TASKS.length} tasks`);
|
// Fallback: seed into legacy TasksDoc
|
||||||
|
ensureTasksDoc(space);
|
||||||
|
_syncServer.changeDoc<TasksDoc>(tasksDocId(space), 'seed tasks (legacy)', (d) => {
|
||||||
|
for (const t of DEMO_SEED_TASKS) {
|
||||||
|
const id = newId();
|
||||||
|
d.tasks[id] = { id, name: t.title, description: '', needs: t.needs, links: [], notes: '' } as any;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`[rTime] Demo seeded for "${space}": ${DEMO_COMMITMENTS.length} commitments, ${DEMO_SEED_TASKS.length} tasks (legacy)`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Commitments API ──
|
// ── Commitments API ──
|
||||||
|
|
@ -437,12 +721,11 @@ routes.delete("/api/commitments/:id", async (c) => {
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Tasks API ──
|
// ── Tasks API (compat shim — reads from WeavingDoc + rTasks board) ──
|
||||||
|
|
||||||
routes.get("/api/tasks", async (c) => {
|
routes.get("/api/tasks", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
|
||||||
// Resolve caller role for membrane filtering
|
|
||||||
let callerRole: SpaceRoleString = 'viewer';
|
let callerRole: SpaceRoleString = 'viewer';
|
||||||
const token = extractToken(c.req.raw.headers);
|
const token = extractToken(c.req.raw.headers);
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|
@ -453,36 +736,93 @@ routes.get("/api/tasks", async (c) => {
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureTasksDoc(space);
|
ensureWeavingDoc(space);
|
||||||
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
const wDoc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
|
||||||
|
const board = getWeavingBoard(space);
|
||||||
|
|
||||||
|
// Build legacy-shaped task list from placed overlays + rTasks items
|
||||||
|
const tasks: any[] = [];
|
||||||
|
for (const [id, ov] of Object.entries(wDoc.weavingOverlays)) {
|
||||||
|
const item = board?.doc.tasks[id];
|
||||||
|
tasks.push({
|
||||||
|
id,
|
||||||
|
name: item?.title || id,
|
||||||
|
description: item?.description || '',
|
||||||
|
needs: ov.needs,
|
||||||
|
links: ov.links,
|
||||||
|
notes: ov.notes,
|
||||||
|
intentFrameId: ov.intentFrameId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also include legacy TasksDoc tasks if any (migration compat)
|
||||||
|
const legacyDoc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space));
|
||||||
|
if (legacyDoc) {
|
||||||
|
for (const t of Object.values(legacyDoc.tasks)) {
|
||||||
|
if (!wDoc.weavingOverlays[t.id]) {
|
||||||
|
tasks.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
tasks: filterArrayByVisibility(Object.values(doc.tasks), callerRole),
|
tasks: filterArrayByVisibility(tasks, callerRole),
|
||||||
connections: Object.values(doc.connections),
|
connections: Object.values(wDoc.connections),
|
||||||
execStates: Object.values(doc.execStates),
|
execStates: Object.values(wDoc.execStates),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/tasks — compat shim: creates rTasks item + places on canvas
|
||||||
routes.post("/api/tasks", async (c) => {
|
routes.post("/api/tasks", async (c) => {
|
||||||
const token = extractToken(c.req.raw.headers);
|
const token = extractToken(c.req.raw.headers);
|
||||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||||
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
let claims;
|
||||||
|
try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||||
|
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { name, description, needs } = body;
|
const { name, description, needs, intentFrameId } = body;
|
||||||
if (!name || !needs) return c.json({ error: "name, needs required" }, 400);
|
if (!name || !needs) return c.json({ error: "name, needs required" }, 400);
|
||||||
|
|
||||||
const id = newId();
|
const board = getWeavingBoard(space);
|
||||||
ensureTasksDoc(space);
|
if (!board) {
|
||||||
|
// Fallback to legacy TasksDoc if no board bound
|
||||||
|
const id = newId();
|
||||||
|
ensureTasksDoc(space);
|
||||||
|
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'add task (legacy)', (d) => {
|
||||||
|
d.tasks[id] = { id, name, description: description || '', needs, links: [], notes: '' } as any;
|
||||||
|
});
|
||||||
|
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
||||||
|
return c.json(doc.tasks[id], 201);
|
||||||
|
}
|
||||||
|
|
||||||
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'add task', (d) => {
|
// Create in rTasks + WeavingDoc
|
||||||
d.tasks[id] = { id, name, description: description || '', needs, links: [], notes: '' } as any;
|
const taskId = newId();
|
||||||
|
const taskItem = createTaskItem(taskId, space, name, {
|
||||||
|
description: description || '',
|
||||||
|
createdBy: (claims.did as string) || null,
|
||||||
|
});
|
||||||
|
_syncServer!.changeDoc<BoardDoc>(board.docId, 'create task from rtime compat', (d) => {
|
||||||
|
d.tasks[taskId] = taskItem as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
ensureWeavingDoc(space);
|
||||||
return c.json(doc.tasks[id], 201);
|
_syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'place task (compat)', (d) => {
|
||||||
|
d.weavingOverlays[taskId] = {
|
||||||
|
rtasksId: taskId,
|
||||||
|
needs: needs || {},
|
||||||
|
canvasX: 400,
|
||||||
|
canvasY: 150,
|
||||||
|
notes: '',
|
||||||
|
links: [],
|
||||||
|
intentFrameId,
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ id: taskId, name, description: description || '', needs, links: [], notes: '' }, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PUT /api/tasks/:id — compat shim: updates rTasks title/desc + WeavingDoc overlay
|
||||||
routes.put("/api/tasks/:id", async (c) => {
|
routes.put("/api/tasks/:id", async (c) => {
|
||||||
const token = extractToken(c.req.raw.headers);
|
const token = extractToken(c.req.raw.headers);
|
||||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||||
|
|
@ -491,25 +831,60 @@ routes.put("/api/tasks/:id", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
ensureTasksDoc(space);
|
|
||||||
|
|
||||||
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
// Update rTasks item (title, description)
|
||||||
if (!doc.tasks[id]) return c.json({ error: "Not found" }, 404);
|
const board = getWeavingBoard(space);
|
||||||
|
if (board?.doc.tasks[id]) {
|
||||||
|
_syncServer!.changeDoc<BoardDoc>(board.docId, 'update task from rtime', (d) => {
|
||||||
|
const t = d.tasks[id];
|
||||||
|
if (body.name !== undefined) t.title = body.name;
|
||||||
|
if (body.description !== undefined) t.description = body.description;
|
||||||
|
t.updatedAt = Date.now();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'update task', (d) => {
|
// Update WeavingDoc overlay (needs, notes, links)
|
||||||
const t = d.tasks[id];
|
ensureWeavingDoc(space);
|
||||||
if (body.name !== undefined) t.name = body.name;
|
const wDoc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
|
||||||
if (body.description !== undefined) t.description = body.description;
|
if (wDoc.weavingOverlays[id]) {
|
||||||
if (body.needs !== undefined) t.needs = body.needs;
|
_syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'update overlay (compat)', (d) => {
|
||||||
if (body.links !== undefined) t.links = body.links;
|
const ov = d.weavingOverlays[id];
|
||||||
if (body.notes !== undefined) t.notes = body.notes;
|
if (body.needs !== undefined) ov.needs = body.needs;
|
||||||
|
if (body.links !== undefined) ov.links = body.links;
|
||||||
|
if (body.notes !== undefined) ov.notes = body.notes;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Legacy fallback
|
||||||
|
ensureTasksDoc(space);
|
||||||
|
const legacyDoc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
||||||
|
if (legacyDoc.tasks[id]) {
|
||||||
|
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'update task (legacy)', (d) => {
|
||||||
|
const t = d.tasks[id];
|
||||||
|
if (body.name !== undefined) t.name = body.name;
|
||||||
|
if (body.description !== undefined) t.description = body.description;
|
||||||
|
if (body.needs !== undefined) t.needs = body.needs;
|
||||||
|
if (body.links !== undefined) t.links = body.links;
|
||||||
|
if (body.notes !== undefined) t.notes = body.notes;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return c.json({ error: "Not found" }, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return merged view
|
||||||
|
const item = board?.doc ? _syncServer!.getDoc<BoardDoc>(board.docId)?.tasks[id] : null;
|
||||||
|
const ov = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))?.weavingOverlays[id];
|
||||||
|
return c.json({
|
||||||
|
id,
|
||||||
|
name: item?.title || id,
|
||||||
|
description: item?.description || '',
|
||||||
|
needs: ov?.needs || {},
|
||||||
|
links: ov?.links || [],
|
||||||
|
notes: ov?.notes || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const updated = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
|
||||||
return c.json(updated.tasks[id]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Connections API ──
|
// ── Connections API (now writes to WeavingDoc) ──
|
||||||
|
|
||||||
routes.post("/api/connections", async (c) => {
|
routes.post("/api/connections", async (c) => {
|
||||||
const token = extractToken(c.req.raw.headers);
|
const token = extractToken(c.req.raw.headers);
|
||||||
|
|
@ -524,35 +899,42 @@ routes.post("/api/connections", async (c) => {
|
||||||
if (typeof hours !== 'number' || hours <= 0) return c.json({ error: "hours must be a positive number" }, 400);
|
if (typeof hours !== 'number' || hours <= 0) return c.json({ error: "hours must be a positive number" }, 400);
|
||||||
|
|
||||||
const id = newId();
|
const id = newId();
|
||||||
ensureTasksDoc(space);
|
ensureWeavingDoc(space);
|
||||||
ensureCommitmentsDoc(space);
|
ensureCommitmentsDoc(space);
|
||||||
|
|
||||||
// Validate: hours <= commitment's available hours
|
// Validate: hours <= commitment's available hours
|
||||||
const cDoc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space));
|
const cDoc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space));
|
||||||
const commitment = cDoc?.items?.[fromCommitmentId];
|
const commitment = cDoc?.items?.[fromCommitmentId];
|
||||||
if (!commitment) return c.json({ error: "Commitment not found" }, 404);
|
if (!commitment) return c.json({ error: "Commitment not found" }, 404);
|
||||||
const tDoc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
|
||||||
const usedHours = Object.values(tDoc.connections)
|
const wDoc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
|
||||||
|
|
||||||
|
// Validate task is placed on canvas
|
||||||
|
if (!wDoc.weavingOverlays[toTaskId]) {
|
||||||
|
return c.json({ error: "Task not placed on weaving canvas" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedHours = Object.values(wDoc.connections)
|
||||||
.filter(cn => cn.fromCommitmentId === fromCommitmentId)
|
.filter(cn => cn.fromCommitmentId === fromCommitmentId)
|
||||||
.reduce((sum, cn) => sum + (cn.hours || 0), 0);
|
.reduce((sum, cn) => sum + (cn.hours || 0), 0);
|
||||||
if (hours > commitment.hours - usedHours) {
|
if (hours > commitment.hours - usedHours) {
|
||||||
return c.json({ error: "Requested hours exceed available hours" }, 400);
|
return c.json({ error: "Requested hours exceed available hours" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'add connection', (d) => {
|
_syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'add connection', (d) => {
|
||||||
d.connections[id] = { id, fromCommitmentId, toTaskId, skill, hours, status: 'proposed' } as any;
|
d.connections[id] = { id, fromCommitmentId, toTaskId, skill, hours, status: 'proposed' } as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify commitment owner that their time was requested
|
// Notify commitment owner
|
||||||
const updatedTDoc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
const board = getWeavingBoard(space);
|
||||||
const task = updatedTDoc.tasks?.[toTaskId];
|
const taskItem = board?.doc.tasks[toTaskId];
|
||||||
if (commitment?.ownerDid && commitment.ownerDid !== claims.did) {
|
if (commitment?.ownerDid && commitment.ownerDid !== claims.did) {
|
||||||
notify({
|
notify({
|
||||||
userDid: commitment.ownerDid,
|
userDid: commitment.ownerDid,
|
||||||
category: 'module',
|
category: 'module',
|
||||||
eventType: 'commitment_requested',
|
eventType: 'commitment_requested',
|
||||||
title: `${hours}hr of your ${commitment.hours}hr ${skill} commitment was requested`,
|
title: `${hours}hr of your ${commitment.hours}hr ${skill} commitment was requested`,
|
||||||
body: task ? `Task: ${task.name}` : undefined,
|
body: taskItem ? `Task: ${taskItem.title}` : undefined,
|
||||||
spaceSlug: space,
|
spaceSlug: space,
|
||||||
moduleId: 'rtime',
|
moduleId: 'rtime',
|
||||||
actionUrl: `/rtime`,
|
actionUrl: `/rtime`,
|
||||||
|
|
@ -561,7 +943,8 @@ routes.post("/api/connections", async (c) => {
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json(updatedTDoc.connections[id], 201);
|
const updatedW = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
|
||||||
|
return c.json(updatedW.connections[id], 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
routes.delete("/api/connections/:id", async (c) => {
|
routes.delete("/api/connections/:id", async (c) => {
|
||||||
|
|
@ -572,22 +955,20 @@ routes.delete("/api/connections/:id", async (c) => {
|
||||||
|
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
ensureTasksDoc(space);
|
ensureWeavingDoc(space);
|
||||||
ensureCommitmentsDoc(space);
|
ensureCommitmentsDoc(space);
|
||||||
|
|
||||||
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
const wDoc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
|
||||||
const connection = doc.connections[id];
|
const connection = wDoc.connections[id];
|
||||||
if (!connection) return c.json({ error: "Not found" }, 404);
|
if (!connection) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
// Look up commitment owner to notify them
|
|
||||||
const cDoc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space));
|
const cDoc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space));
|
||||||
const commitment = cDoc?.items?.[connection.fromCommitmentId];
|
const commitment = cDoc?.items?.[connection.fromCommitmentId];
|
||||||
|
|
||||||
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'remove connection', (d) => {
|
_syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'remove connection', (d) => {
|
||||||
delete d.connections[id];
|
delete d.connections[id];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify commitment owner that the request was declined
|
|
||||||
if (commitment?.ownerDid && commitment.ownerDid !== (claims.did as string)) {
|
if (commitment?.ownerDid && commitment.ownerDid !== (claims.did as string)) {
|
||||||
notify({
|
notify({
|
||||||
userDid: commitment.ownerDid,
|
userDid: commitment.ownerDid,
|
||||||
|
|
@ -616,30 +997,29 @@ routes.patch("/api/connections/:id", async (c) => {
|
||||||
const { status } = body;
|
const { status } = body;
|
||||||
if (status !== 'committed' && status !== 'declined') return c.json({ error: "status must be 'committed' or 'declined'" }, 400);
|
if (status !== 'committed' && status !== 'declined') return c.json({ error: "status must be 'committed' or 'declined'" }, 400);
|
||||||
|
|
||||||
ensureTasksDoc(space);
|
ensureWeavingDoc(space);
|
||||||
ensureCommitmentsDoc(space);
|
ensureCommitmentsDoc(space);
|
||||||
|
|
||||||
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
const wDoc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
|
||||||
const connection = doc.connections[id];
|
const connection = wDoc.connections[id];
|
||||||
if (!connection) return c.json({ error: "Not found" }, 404);
|
if (!connection) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
if (status === 'declined') {
|
if (status === 'declined') {
|
||||||
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'decline connection', (d) => {
|
_syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'decline connection', (d) => {
|
||||||
delete d.connections[id];
|
delete d.connections[id];
|
||||||
});
|
});
|
||||||
return c.json({ ok: true, deleted: true });
|
return c.json({ ok: true, deleted: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// status === 'committed'
|
_syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'approve connection', (d) => {
|
||||||
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'approve connection', (d) => {
|
|
||||||
d.connections[id].status = 'committed' as any;
|
d.connections[id].status = 'committed' as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
const updated = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
const updated = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
|
||||||
return c.json(updated.connections[id]);
|
return c.json(updated.connections[id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Exec State API ──
|
// ── Exec State API (compat — redirects to WeavingDoc) ──
|
||||||
|
|
||||||
routes.put("/api/tasks/:id/exec-state", async (c) => {
|
routes.put("/api/tasks/:id/exec-state", async (c) => {
|
||||||
const token = extractToken(c.req.raw.headers);
|
const token = extractToken(c.req.raw.headers);
|
||||||
|
|
@ -650,9 +1030,9 @@ routes.put("/api/tasks/:id/exec-state", async (c) => {
|
||||||
const taskId = c.req.param("id");
|
const taskId = c.req.param("id");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { steps, launchedAt } = body;
|
const { steps, launchedAt } = body;
|
||||||
ensureTasksDoc(space);
|
ensureWeavingDoc(space);
|
||||||
|
|
||||||
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'update exec state', (d) => {
|
_syncServer!.changeDoc<WeavingDoc>(weavingDocId(space), 'update exec state', (d) => {
|
||||||
if (!d.execStates[taskId]) {
|
if (!d.execStates[taskId]) {
|
||||||
d.execStates[taskId] = { taskId, steps: {}, launchedAt: undefined } as any;
|
d.execStates[taskId] = { taskId, steps: {}, launchedAt: undefined } as any;
|
||||||
}
|
}
|
||||||
|
|
@ -660,7 +1040,7 @@ routes.put("/api/tasks/:id/exec-state", async (c) => {
|
||||||
if (launchedAt) d.execStates[taskId].launchedAt = launchedAt;
|
if (launchedAt) d.execStates[taskId].launchedAt = launchedAt;
|
||||||
});
|
});
|
||||||
|
|
||||||
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
const doc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
|
||||||
return c.json(doc.execStates[taskId]);
|
return c.json(doc.execStates[taskId]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -750,20 +1130,40 @@ routes.post("/api/tasks/:id/export-to-backlog", async (c) => {
|
||||||
|
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
const taskId = c.req.param("id");
|
const taskId = c.req.param("id");
|
||||||
ensureTasksDoc(space);
|
|
||||||
|
|
||||||
|
// Try WeavingDoc + rTasks first, fallback to legacy TasksDoc
|
||||||
|
ensureWeavingDoc(space);
|
||||||
|
const wDoc = _syncServer!.getDoc<WeavingDoc>(weavingDocId(space))!;
|
||||||
|
const ov = wDoc.weavingOverlays[taskId];
|
||||||
|
const board = getWeavingBoard(space);
|
||||||
|
const item = board?.doc.tasks[taskId];
|
||||||
|
|
||||||
|
if (ov && item) {
|
||||||
|
const estimatedHours = Object.values(ov.needs).reduce((sum, h) => sum + h, 0);
|
||||||
|
const acceptanceCriteria = Object.entries(ov.needs).map(([skill, hours]) =>
|
||||||
|
`${SKILL_LABELS[skill as Skill] || skill}: ${hours}h`
|
||||||
|
);
|
||||||
|
return c.json({
|
||||||
|
title: item.title,
|
||||||
|
description: item.description?.replace(/\[backlog:[^\]]+\]/g, '').trim() || '',
|
||||||
|
estimatedHours,
|
||||||
|
labels: Object.keys(ov.needs),
|
||||||
|
notes: ov.notes || '',
|
||||||
|
acceptanceCriteria,
|
||||||
|
rtime: { taskId, space },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy fallback
|
||||||
|
ensureTasksDoc(space);
|
||||||
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
||||||
const task = doc.tasks[taskId];
|
const task = doc.tasks[taskId];
|
||||||
if (!task) return c.json({ error: "Task not found" }, 404);
|
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);
|
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]) =>
|
const acceptanceCriteria = Object.entries(task.needs).map(([skill, hours]) =>
|
||||||
`${SKILL_LABELS[skill as Skill] || skill}: ${hours}h`
|
`${SKILL_LABELS[skill as Skill] || skill}: ${hours}h`
|
||||||
);
|
);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
title: task.name,
|
title: task.name,
|
||||||
description: task.description?.replace(/\[backlog:[^\]]+\]/g, '').trim() || '',
|
description: task.description?.replace(/\[backlog:[^\]]+\]/g, '').trim() || '',
|
||||||
|
|
@ -771,10 +1171,7 @@ routes.post("/api/tasks/:id/export-to-backlog", async (c) => {
|
||||||
labels: Object.keys(task.needs),
|
labels: Object.keys(task.needs),
|
||||||
notes: task.notes || '',
|
notes: task.notes || '',
|
||||||
acceptanceCriteria,
|
acceptanceCriteria,
|
||||||
rtime: {
|
rtime: { taskId: task.id, space },
|
||||||
taskId: task.id,
|
|
||||||
space,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -805,7 +1202,8 @@ export const timeModule: RSpaceModule = {
|
||||||
scoping: { defaultScope: 'space', userConfigurable: false },
|
scoping: { defaultScope: 'space', userConfigurable: false },
|
||||||
docSchemas: [
|
docSchemas: [
|
||||||
{ pattern: '{space}:rtime:commitments', description: 'Commitment pool', init: commitmentsSchema.init },
|
{ pattern: '{space}:rtime:commitments', description: 'Commitment pool', init: commitmentsSchema.init },
|
||||||
{ pattern: '{space}:rtime:tasks', description: 'Tasks, connections, exec states', init: tasksSchema.init },
|
{ pattern: '{space}:rtime:tasks', description: 'Tasks, connections, exec states (legacy)', init: tasksSchema.init },
|
||||||
|
{ pattern: '{space}:rtime:weaving', description: 'Weaving overlays, connections, exec states (rTasks integration)', init: weavingSchema.init },
|
||||||
{ pattern: '{space}:rtime:intents', description: 'Intent pool (offers & needs)', init: intentsSchema.init },
|
{ pattern: '{space}:rtime:intents', description: 'Intent pool (offers & needs)', init: intentsSchema.init },
|
||||||
{ 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 },
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,33 @@ export interface ExternalTimeLogsDoc {
|
||||||
logs: Record<string, ExternalTimeLog>;
|
logs: Record<string, ExternalTimeLog>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Weaving overlay (rTasks integration) ──
|
||||||
|
|
||||||
|
export interface WeavingOverlay {
|
||||||
|
rtasksId: string;
|
||||||
|
needs: Record<string, number>;
|
||||||
|
canvasX: number;
|
||||||
|
canvasY: number;
|
||||||
|
notes: string;
|
||||||
|
links: { label: string; url: string }[];
|
||||||
|
intentFrameId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeavingDoc {
|
||||||
|
meta: {
|
||||||
|
module: string;
|
||||||
|
collection: string;
|
||||||
|
version: number;
|
||||||
|
spaceSlug: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
boardSlug: string;
|
||||||
|
weavingOverlays: Record<string, WeavingOverlay>;
|
||||||
|
connections: Record<string, Connection>;
|
||||||
|
execStates: Record<string, ExecState>;
|
||||||
|
projectFrames: Record<string, ProjectFrame>;
|
||||||
|
}
|
||||||
|
|
||||||
// ── DocId helpers ──
|
// ── DocId helpers ──
|
||||||
|
|
||||||
export function commitmentsDocId(space: string) {
|
export function commitmentsDocId(space: string) {
|
||||||
|
|
@ -154,6 +181,10 @@ export function tasksDocId(space: string) {
|
||||||
return `${space}:rtime:tasks` as const;
|
return `${space}:rtime:tasks` as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function weavingDocId(space: string) {
|
||||||
|
return `${space}:rtime:weaving` as const;
|
||||||
|
}
|
||||||
|
|
||||||
export function externalTimeLogsDocId(space: string) {
|
export function externalTimeLogsDocId(space: string) {
|
||||||
return `${space}:rtime:external-time-logs` as const;
|
return `${space}:rtime:external-time-logs` as const;
|
||||||
}
|
}
|
||||||
|
|
@ -195,6 +226,26 @@ export const tasksSchema: DocSchema<TasksDoc> = {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const weavingSchema: DocSchema<WeavingDoc> = {
|
||||||
|
module: 'rtime',
|
||||||
|
collection: 'weaving',
|
||||||
|
version: 1,
|
||||||
|
init: (): WeavingDoc => ({
|
||||||
|
meta: {
|
||||||
|
module: 'rtime',
|
||||||
|
collection: 'weaving',
|
||||||
|
version: 1,
|
||||||
|
spaceSlug: '',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
boardSlug: '',
|
||||||
|
weavingOverlays: {},
|
||||||
|
connections: {},
|
||||||
|
execStates: {},
|
||||||
|
projectFrames: {},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
export const externalTimeLogsSchema: DocSchema<ExternalTimeLogsDoc> = {
|
export const externalTimeLogsSchema: DocSchema<ExternalTimeLogsDoc> = {
|
||||||
module: 'rtime',
|
module: 'rtime',
|
||||||
collection: 'external-time-logs',
|
collection: 'external-time-logs',
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,11 @@
|
||||||
import * as Automerge from '@automerge/automerge';
|
import * as Automerge from '@automerge/automerge';
|
||||||
import type { SyncServer } from '../../server/local-first/sync-server';
|
import type { SyncServer } from '../../server/local-first/sync-server';
|
||||||
import { confirmBurn, reverseBurn } from '../../server/token-service';
|
import { confirmBurn, reverseBurn } from '../../server/token-service';
|
||||||
import { commitmentsDocId, tasksDocId } from './schemas';
|
import { commitmentsDocId, tasksDocId, weavingDocId } from './schemas';
|
||||||
import type { CommitmentsDoc, TasksDoc, Skill } from './schemas';
|
import type { CommitmentsDoc, TasksDoc, WeavingDoc, Skill } from './schemas';
|
||||||
|
import { weavingSchema } from './schemas';
|
||||||
|
import { boardDocId, createTaskItem } from '../rtasks/schemas';
|
||||||
|
import type { BoardDoc } from '../rtasks/schemas';
|
||||||
import {
|
import {
|
||||||
intentsDocId, solverResultsDocId,
|
intentsDocId, solverResultsDocId,
|
||||||
skillCurvesDocId, reputationDocId,
|
skillCurvesDocId, reputationDocId,
|
||||||
|
|
@ -114,7 +117,7 @@ export async function settleResult(
|
||||||
const offerIntents = intents.filter(i => i.type === 'offer');
|
const offerIntents = intents.filter(i => i.type === 'offer');
|
||||||
const needIntentsAll = intents.filter(i => i.type === 'need');
|
const needIntentsAll = intents.filter(i => i.type === 'need');
|
||||||
|
|
||||||
// Create a task for this collaboration
|
// Create a task in rTasks board + weaving overlay
|
||||||
const taskId = crypto.randomUUID();
|
const taskId = crypto.randomUUID();
|
||||||
const taskSkills = [...new Set(intents.map(i => i.skill))];
|
const taskSkills = [...new Set(intents.map(i => i.skill))];
|
||||||
const taskNeeds: Record<string, number> = {};
|
const taskNeeds: Record<string, number> = {};
|
||||||
|
|
@ -122,28 +125,65 @@ export async function settleResult(
|
||||||
taskNeeds[need.skill] = (taskNeeds[need.skill] || 0) + need.hours;
|
taskNeeds[need.skill] = (taskNeeds[need.skill] || 0) + need.hours;
|
||||||
}
|
}
|
||||||
|
|
||||||
syncServer.changeDoc<TasksDoc>(tasksDocId(space), 'settlement: create task', (d) => {
|
const taskTitle = `Collaboration: ${taskSkills.map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' + ')}`;
|
||||||
d.tasks[taskId] = {
|
const taskDesc = `Auto-generated from solver result. Members: ${result.members.length}`;
|
||||||
id: taskId,
|
|
||||||
name: `Collaboration: ${taskSkills.map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' + ')}`,
|
// Try to create in rTasks board
|
||||||
description: `Auto-generated from solver result. Members: ${result.members.length}`,
|
const boardSlug = space; // default board
|
||||||
needs: taskNeeds,
|
const bDocId = boardDocId(space, boardSlug);
|
||||||
links: [],
|
const boardDoc = syncServer.getDoc<BoardDoc>(bDocId);
|
||||||
notes: `Settled from solver result ${resultId}`,
|
|
||||||
} as any;
|
if (boardDoc) {
|
||||||
});
|
const taskItem = createTaskItem(taskId, space, taskTitle, { description: taskDesc });
|
||||||
|
syncServer.changeDoc<BoardDoc>(bDocId, 'settlement: create rTasks item', (d) => {
|
||||||
|
d.tasks[taskId] = taskItem as any;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: create in legacy TasksDoc
|
||||||
|
syncServer.changeDoc<TasksDoc>(tasksDocId(space), 'settlement: create task (legacy)', (d) => {
|
||||||
|
d.tasks[taskId] = {
|
||||||
|
id: taskId, name: taskTitle, description: taskDesc,
|
||||||
|
needs: taskNeeds, links: [], notes: `Settled from solver result ${resultId}`,
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
}
|
||||||
outcome.tasksCreated = 1;
|
outcome.tasksCreated = 1;
|
||||||
|
|
||||||
// Create connections (offer → task)
|
// Ensure WeavingDoc and create overlay + connections
|
||||||
syncServer.changeDoc<TasksDoc>(tasksDocId(space), 'settlement: create connections', (d) => {
|
let wDoc = syncServer.getDoc<WeavingDoc>(weavingDocId(space));
|
||||||
|
if (!wDoc) {
|
||||||
|
wDoc = Automerge.change(Automerge.init<WeavingDoc>(), 'init weaving', (d) => {
|
||||||
|
const init = weavingSchema.init();
|
||||||
|
Object.assign(d, init);
|
||||||
|
d.meta.spaceSlug = space;
|
||||||
|
});
|
||||||
|
syncServer.setDoc(weavingDocId(space), wDoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create weaving overlay
|
||||||
|
syncServer.changeDoc<WeavingDoc>(weavingDocId(space), 'settlement: create overlay', (d) => {
|
||||||
|
d.weavingOverlays[taskId] = {
|
||||||
|
rtasksId: taskId,
|
||||||
|
needs: taskNeeds,
|
||||||
|
canvasX: 400,
|
||||||
|
canvasY: 150,
|
||||||
|
notes: `Settled from solver result ${resultId}`,
|
||||||
|
links: [],
|
||||||
|
intentFrameId: resultId,
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create connections in WeavingDoc (offer → task)
|
||||||
|
syncServer.changeDoc<WeavingDoc>(weavingDocId(space), 'settlement: create connections', (d) => {
|
||||||
for (const offer of offerIntents) {
|
for (const offer of offerIntents) {
|
||||||
// Find or create a commitment for this offer in CommitmentsDoc
|
|
||||||
const connId = crypto.randomUUID();
|
const connId = crypto.randomUUID();
|
||||||
d.connections[connId] = {
|
d.connections[connId] = {
|
||||||
id: connId,
|
id: connId,
|
||||||
fromCommitmentId: offer.id, // Using intent ID as reference
|
fromCommitmentId: offer.id,
|
||||||
toTaskId: taskId,
|
toTaskId: taskId,
|
||||||
skill: offer.skill,
|
skill: offer.skill,
|
||||||
|
hours: offer.hours,
|
||||||
|
status: 'committed',
|
||||||
} as any;
|
} as any;
|
||||||
outcome.connectionsCreated++;
|
outcome.connectionsCreated++;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
/**
|
||||||
|
* Migration: rTime TasksDoc → rTasks BoardDoc + WeavingDoc
|
||||||
|
*
|
||||||
|
* Reads all {space}:rtime:tasks docs and:
|
||||||
|
* 1. Creates TaskItem entries in the rTasks board (reusing Task.id)
|
||||||
|
* 2. Creates WeavingOverlay entries in the WeavingDoc
|
||||||
|
* 3. Moves connections + execStates from TasksDoc → WeavingDoc
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/migrate-rtime-tasks.ts [space]
|
||||||
|
* Default space: demo
|
||||||
|
*
|
||||||
|
* This script must be run on the server where Automerge data lives.
|
||||||
|
* It operates on the SyncServer data files directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Automerge from '@automerge/automerge';
|
||||||
|
import type { SyncServer } from '../server/local-first/sync-server';
|
||||||
|
import {
|
||||||
|
tasksDocId, weavingDocId, weavingSchema,
|
||||||
|
} from '../modules/rtime/schemas';
|
||||||
|
import type { TasksDoc, WeavingDoc, Task, Connection, ExecState } from '../modules/rtime/schemas';
|
||||||
|
import { boardDocId, createTaskItem, boardSchema } from '../modules/rtasks/schemas';
|
||||||
|
import type { BoardDoc, TaskItem } from '../modules/rtasks/schemas';
|
||||||
|
|
||||||
|
const space = process.argv[2] || 'demo';
|
||||||
|
|
||||||
|
console.log(`[migrate] Starting rTime → rTasks migration for space: ${space}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone migration logic. Call with a SyncServer instance.
|
||||||
|
* This is exported so it can also be called from server startup if needed.
|
||||||
|
*/
|
||||||
|
export function migrateRTimeTasks(syncServer: SyncServer, spaceSlug: string): {
|
||||||
|
tasksCreated: number;
|
||||||
|
overlaysCreated: number;
|
||||||
|
connectionsMoved: number;
|
||||||
|
execStatesMoved: number;
|
||||||
|
} {
|
||||||
|
const result = { tasksCreated: 0, overlaysCreated: 0, connectionsMoved: 0, execStatesMoved: 0 };
|
||||||
|
|
||||||
|
// 1. Read legacy TasksDoc
|
||||||
|
const oldDocId = tasksDocId(spaceSlug);
|
||||||
|
const oldDoc = syncServer.getDoc<TasksDoc>(oldDocId);
|
||||||
|
if (!oldDoc) {
|
||||||
|
console.log(`[migrate] No TasksDoc found at ${oldDocId}, nothing to migrate.`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldTasks = Object.values(oldDoc.tasks || {});
|
||||||
|
const oldConnections = Object.values(oldDoc.connections || {});
|
||||||
|
const oldExecStates = Object.values(oldDoc.execStates || {});
|
||||||
|
|
||||||
|
if (oldTasks.length === 0 && oldConnections.length === 0) {
|
||||||
|
console.log(`[migrate] TasksDoc is empty, nothing to migrate.`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[migrate] Found ${oldTasks.length} tasks, ${oldConnections.length} connections, ${oldExecStates.length} exec states`);
|
||||||
|
|
||||||
|
// 2. Ensure rTasks board exists
|
||||||
|
const bDocId = boardDocId(spaceSlug, spaceSlug);
|
||||||
|
let boardDoc = syncServer.getDoc<BoardDoc>(bDocId);
|
||||||
|
if (!boardDoc) {
|
||||||
|
boardDoc = Automerge.change(Automerge.init<BoardDoc>(), 'init board for migration', (d) => {
|
||||||
|
const init = boardSchema.init();
|
||||||
|
Object.assign(d, init);
|
||||||
|
d.meta.spaceSlug = spaceSlug;
|
||||||
|
d.board.id = spaceSlug;
|
||||||
|
d.board.slug = spaceSlug;
|
||||||
|
d.board.name = `${spaceSlug} Board`;
|
||||||
|
});
|
||||||
|
syncServer.setDoc(bDocId, boardDoc);
|
||||||
|
console.log(`[migrate] Created rTasks board: ${bDocId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create TaskItem for each rTime Task (reuse ID)
|
||||||
|
for (const task of oldTasks) {
|
||||||
|
if (boardDoc.tasks[task.id]) {
|
||||||
|
console.log(`[migrate] Task ${task.id} already exists in board, skipping.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const taskItem = createTaskItem(task.id, spaceSlug, task.name, {
|
||||||
|
description: task.description || '',
|
||||||
|
});
|
||||||
|
syncServer.changeDoc<BoardDoc>(bDocId, `migrate task: ${task.name}`, (d) => {
|
||||||
|
d.tasks[task.id] = taskItem as any;
|
||||||
|
});
|
||||||
|
result.tasksCreated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Ensure WeavingDoc exists
|
||||||
|
const wDocId = weavingDocId(spaceSlug);
|
||||||
|
let wDoc = syncServer.getDoc<WeavingDoc>(wDocId);
|
||||||
|
if (!wDoc) {
|
||||||
|
wDoc = Automerge.change(Automerge.init<WeavingDoc>(), 'init weaving for migration', (d) => {
|
||||||
|
const init = weavingSchema.init();
|
||||||
|
Object.assign(d, init);
|
||||||
|
d.meta.spaceSlug = spaceSlug;
|
||||||
|
d.boardSlug = spaceSlug;
|
||||||
|
});
|
||||||
|
syncServer.setDoc(wDocId, wDoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Create WeavingOverlay for each task (with needs, notes, links)
|
||||||
|
let canvasX = 300;
|
||||||
|
for (const task of oldTasks) {
|
||||||
|
if (wDoc.weavingOverlays[task.id]) {
|
||||||
|
console.log(`[migrate] Overlay for ${task.id} already exists, skipping.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
syncServer.changeDoc<WeavingDoc>(wDocId, `migrate overlay: ${task.name}`, (d) => {
|
||||||
|
d.weavingOverlays[task.id] = {
|
||||||
|
rtasksId: task.id,
|
||||||
|
needs: task.needs || {},
|
||||||
|
canvasX,
|
||||||
|
canvasY: 150,
|
||||||
|
notes: task.notes || '',
|
||||||
|
links: task.links || [],
|
||||||
|
intentFrameId: task.intentFrameId,
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
canvasX += 280;
|
||||||
|
result.overlaysCreated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Move connections from TasksDoc → WeavingDoc
|
||||||
|
for (const conn of oldConnections) {
|
||||||
|
syncServer.changeDoc<WeavingDoc>(wDocId, `migrate connection: ${conn.id}`, (d) => {
|
||||||
|
if (!d.connections[conn.id]) {
|
||||||
|
d.connections[conn.id] = { ...conn } as any;
|
||||||
|
result.connectionsMoved++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Move execStates from TasksDoc → WeavingDoc
|
||||||
|
for (const es of oldExecStates) {
|
||||||
|
syncServer.changeDoc<WeavingDoc>(wDocId, `migrate exec state: ${es.taskId}`, (d) => {
|
||||||
|
if (!d.execStates[es.taskId]) {
|
||||||
|
d.execStates[es.taskId] = { ...es } as any;
|
||||||
|
result.execStatesMoved++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh doc after changes
|
||||||
|
const finalW = syncServer.getDoc<WeavingDoc>(wDocId)!;
|
||||||
|
result.connectionsMoved = Object.keys(finalW.connections).length;
|
||||||
|
result.execStatesMoved = Object.keys(finalW.execStates).length;
|
||||||
|
|
||||||
|
console.log(`[migrate] Migration complete:
|
||||||
|
Tasks created in rTasks: ${result.tasksCreated}
|
||||||
|
Weaving overlays created: ${result.overlaysCreated}
|
||||||
|
Connections moved: ${result.connectionsMoved}
|
||||||
|
Exec states moved: ${result.execStatesMoved}`);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI entry point — only runs when executed directly
|
||||||
|
if (process.argv[1]?.includes('migrate-rtime-tasks')) {
|
||||||
|
console.log('[migrate] This script must be imported and called with a SyncServer instance.');
|
||||||
|
console.log('[migrate] Example: import { migrateRTimeTasks } from "./scripts/migrate-rtime-tasks";');
|
||||||
|
console.log('[migrate] migrateRTimeTasks(syncServer, "demo");');
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* MCP tools for rTime (commitments, tasks, external time logs).
|
* MCP tools for rTime (commitments, woven tasks, external time logs).
|
||||||
*
|
*
|
||||||
* Tools: rtime_list_commitments, rtime_list_tasks,
|
* Tools: rtime_list_commitments, rtime_list_woven_tasks, rtime_place_task,
|
||||||
* rtime_list_time_logs, rtime_create_commitment
|
* rtime_list_time_logs, rtime_create_commitment
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -10,15 +10,17 @@ import { z } from "zod";
|
||||||
import type { SyncServer } from "../local-first/sync-server";
|
import type { SyncServer } from "../local-first/sync-server";
|
||||||
import {
|
import {
|
||||||
commitmentsDocId,
|
commitmentsDocId,
|
||||||
tasksDocId,
|
weavingDocId,
|
||||||
externalTimeLogsDocId,
|
externalTimeLogsDocId,
|
||||||
} from "../../modules/rtime/schemas";
|
} from "../../modules/rtime/schemas";
|
||||||
import type {
|
import type {
|
||||||
CommitmentsDoc,
|
CommitmentsDoc,
|
||||||
TasksDoc,
|
WeavingDoc,
|
||||||
ExternalTimeLogsDoc,
|
ExternalTimeLogsDoc,
|
||||||
Skill,
|
Skill,
|
||||||
} from "../../modules/rtime/schemas";
|
} from "../../modules/rtime/schemas";
|
||||||
|
import { boardDocId } from "../../modules/rtasks/schemas";
|
||||||
|
import type { BoardDoc } from "../../modules/rtasks/schemas";
|
||||||
import { resolveAccess, accessDeniedResponse } from "./_auth";
|
import { resolveAccess, accessDeniedResponse } from "./_auth";
|
||||||
import { filterArrayByVisibility } from "../../shared/membrane";
|
import { filterArrayByVisibility } from "../../shared/membrane";
|
||||||
|
|
||||||
|
|
@ -66,8 +68,8 @@ export function registerTimeTools(server: McpServer, syncServer: SyncServer) {
|
||||||
);
|
);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
"rtime_list_tasks",
|
"rtime_list_woven_tasks",
|
||||||
"List rTime tasks with their needs maps",
|
"List tasks placed on the rTime weaving canvas with overlay data (needs, position, connections)",
|
||||||
{
|
{
|
||||||
space: z.string().describe("Space slug"),
|
space: z.string().describe("Space slug"),
|
||||||
token: z.string().optional().describe("JWT auth token"),
|
token: z.string().optional().describe("JWT auth token"),
|
||||||
|
|
@ -77,24 +79,80 @@ export function registerTimeTools(server: McpServer, syncServer: SyncServer) {
|
||||||
const access = await resolveAccess(token, space, false);
|
const access = await resolveAccess(token, space, false);
|
||||||
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
||||||
|
|
||||||
const doc = syncServer.getDoc<TasksDoc>(tasksDocId(space));
|
const wDoc = syncServer.getDoc<WeavingDoc>(weavingDocId(space));
|
||||||
if (!doc) {
|
if (!wDoc) {
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ error: "No tasks data found" }) }] };
|
return { content: [{ type: "text", text: JSON.stringify({ error: "No weaving data found" }) }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const tasks = filterArrayByVisibility(Object.values(doc.tasks || {}), access.role)
|
// Look up rTasks board for task titles
|
||||||
|
const boardSlug = wDoc.boardSlug || space;
|
||||||
|
const board = syncServer.getDoc<BoardDoc>(boardDocId(space, boardSlug));
|
||||||
|
|
||||||
|
const tasks = Object.entries(wDoc.weavingOverlays || {})
|
||||||
.slice(0, limit || 50)
|
.slice(0, limit || 50)
|
||||||
.map(t => ({
|
.map(([id, ov]) => {
|
||||||
id: t.id,
|
const item = board?.tasks[id];
|
||||||
name: t.name,
|
return {
|
||||||
description: t.description,
|
id,
|
||||||
needs: t.needs,
|
title: item?.title || id,
|
||||||
}));
|
status: item?.status || 'TODO',
|
||||||
|
description: item?.description || '',
|
||||||
|
needs: ov.needs,
|
||||||
|
canvasX: ov.canvasX,
|
||||||
|
canvasY: ov.canvasY,
|
||||||
|
notes: ov.notes,
|
||||||
|
links: ov.links,
|
||||||
|
connectionCount: Object.values(wDoc.connections || {}).filter(c => c.toTaskId === id).length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }] };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"rtime_place_task",
|
||||||
|
"Place an rTasks item onto the weaving canvas with skill needs (requires auth token)",
|
||||||
|
{
|
||||||
|
space: z.string().describe("Space slug"),
|
||||||
|
token: z.string().describe("JWT auth token"),
|
||||||
|
task_id: z.string().describe("rTasks task ID to place on canvas"),
|
||||||
|
needs: z.record(z.number()).describe("Skill-to-hours map (e.g. {tech: 4, design: 2})"),
|
||||||
|
canvas_x: z.number().optional().describe("Canvas X position (default 400)"),
|
||||||
|
canvas_y: z.number().optional().describe("Canvas Y position (default 150)"),
|
||||||
|
},
|
||||||
|
async ({ space, token, task_id, needs, canvas_x, canvas_y }) => {
|
||||||
|
const access = await resolveAccess(token, space, true);
|
||||||
|
if (!access.allowed) return accessDeniedResponse(access.reason!);
|
||||||
|
|
||||||
|
const wDoc = syncServer.getDoc<WeavingDoc>(weavingDocId(space));
|
||||||
|
if (!wDoc) {
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify({ error: "No weaving doc found" }) }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify task exists in rTasks board
|
||||||
|
const boardSlug = wDoc.boardSlug || space;
|
||||||
|
const board = syncServer.getDoc<BoardDoc>(boardDocId(space, boardSlug));
|
||||||
|
if (!board?.tasks[task_id]) {
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify({ error: "Task not found in rTasks board" }) }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
syncServer.changeDoc<WeavingDoc>(weavingDocId(space), `Place task ${task_id} on canvas`, (d) => {
|
||||||
|
if (!d.weavingOverlays) (d as any).weavingOverlays = {};
|
||||||
|
d.weavingOverlays[task_id] = {
|
||||||
|
rtasksId: task_id,
|
||||||
|
needs,
|
||||||
|
canvasX: canvas_x ?? 400,
|
||||||
|
canvasY: canvas_y ?? 150,
|
||||||
|
notes: '',
|
||||||
|
links: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify({ id: task_id, placed: true, needs }) }] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
"rtime_list_time_logs",
|
"rtime_list_time_logs",
|
||||||
"List external time logs (imported from backlog-md)",
|
"List external time logs (imported from backlog-md)",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue