Merge branch 'dev'
CI/CD / deploy (push) Failing after 2m7s Details

This commit is contained in:
Jeff Emmett 2026-04-15 17:16:58 -04:00
commit 7214599f5a
8 changed files with 305 additions and 4 deletions

View File

@ -470,6 +470,46 @@ registry.push(
}, },
); );
// ── rTime Weaving Coverage Applet ──
registry.push({
declaration: {
name: "create_weaving_coverage",
description: "Create a weaving coverage applet card on the canvas. Shows per-task skill fulfillment bars from the commitment weaving system. Self-fetches weaving data and outputs coverage summary for downstream applets.",
parameters: {
type: "object",
properties: {},
required: [],
},
},
tagName: "folk-applet",
moduleId: "rtime",
buildProps: () => ({
moduleId: "rtime",
appletId: "weaving-coverage",
}),
actionLabel: () => "Created weaving coverage applet",
});
// ── rTasks Resource Coverage Applet ──
registry.push({
declaration: {
name: "create_resource_coverage",
description: "Create a resource coverage applet card on the canvas. Shows task readiness status (ready/partial/unresourced) based on commitment coverage data piped in via the coverage-in port.",
parameters: {
type: "object",
properties: {},
required: [],
},
},
tagName: "folk-applet",
moduleId: "rtasks",
buildProps: () => ({
moduleId: "rtasks",
appletId: "resource-coverage",
}),
actionLabel: () => "Created resource coverage applet",
});
// ── rExchange P2P Exchange Tool ── // ── rExchange P2P Exchange Tool ──
registry.push({ registry.push({
declaration: { declaration: {

View File

@ -205,6 +205,9 @@ export class FolkApplet extends FolkShape {
// Instance-level port descriptors (override static) // Instance-level port descriptors (override static)
#instancePorts: PortDescriptor[] = []; #instancePorts: PortDescriptor[] = [];
// Live data polling timer
#liveDataTimer: ReturnType<typeof setInterval> | null = null;
get moduleId() { return this.#moduleId; } get moduleId() { return this.#moduleId; }
set moduleId(v: string) { set moduleId(v: string) {
this.#moduleId = v; this.#moduleId = v;
@ -333,9 +336,35 @@ export class FolkApplet extends FolkShape {
detail: { moduleId: this.#moduleId, appletId: this.#appletId, shapeId: this.id }, detail: { moduleId: this.#moduleId, appletId: this.#appletId, shapeId: this.id },
})); }));
// Start self-fetch polling if the applet defines fetchLiveData
this.#startLiveDataPolling();
return root; return root;
} }
disconnectedCallback() {
if (this.#liveDataTimer) {
clearInterval(this.#liveDataTimer);
this.#liveDataTimer = null;
}
}
#startLiveDataPolling(): void {
const def = getAppletDef(this.#moduleId, this.#appletId);
if (!def?.fetchLiveData) return;
const space = (this.closest("[space]") as any)?.getAttribute("space") || "";
const doFetch = () => {
def.fetchLiveData!(space).then(snapshot => {
this.updateLiveData(snapshot);
}).catch(() => {});
};
// Fetch immediately, then every 30s
doFetch();
this.#liveDataTimer = setInterval(doFetch, 30_000);
}
#renderPorts(): void { #renderPorts(): void {
this.#wrapper.querySelectorAll(".port-chip").forEach(el => el.remove()); this.#wrapper.querySelectorAll(".port-chip").forEach(el => el.remove());

View File

@ -8,6 +8,7 @@
import { FolkShape } from "./folk-shape"; import { FolkShape } from "./folk-shape";
import { css, html } from "./tags"; import { css, html } from "./tags";
import { getModuleApiBase } from "../shared/url-helpers"; import { getModuleApiBase } from "../shared/url-helpers";
import type { PortDescriptor } from "./data-types";
// ── Skill constants (mirrored from rtime/schemas to avoid server import) ── // ── Skill constants (mirrored from rtime/schemas to avoid server import) ──
@ -195,6 +196,10 @@ declare global {
export class FolkCommitmentPool extends FolkShape { export class FolkCommitmentPool extends FolkShape {
static override tagName = "folk-commitment-pool"; static override tagName = "folk-commitment-pool";
static override portDescriptors: PortDescriptor[] = [
{ name: "pool-out", type: "json", direction: "output" },
];
static { static {
const sheet = new CSSStyleSheet(); const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n"); const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n");
@ -265,6 +270,7 @@ export class FolkCommitmentPool extends FolkShape {
override createRenderRoot() { override createRenderRoot() {
const root = super.createRenderRoot(); const root = super.createRenderRoot();
this.initPorts();
this.#wrapper = document.createElement("div"); this.#wrapper = document.createElement("div");
this.#wrapper.style.cssText = "width:100%;height:100%;position:relative;"; this.#wrapper.style.cssText = "width:100%;height:100%;position:relative;";
@ -337,6 +343,20 @@ export class FolkCommitmentPool extends FolkShape {
} }
return new Orb(c, cx, cy, r); return new Orb(c, cx, cy, r);
}); });
// Emit pool snapshot on output port
this.#emitPoolSnapshot(commitments);
}
#emitPoolSnapshot(commitments: PoolCommitment[]) {
const bySkill: Record<string, { available: number; committed: number }> = {};
let totalHours = 0;
for (const c of commitments) {
totalHours += c.hours;
if (!bySkill[c.skill]) bySkill[c.skill] = { available: 0, committed: 0 };
bySkill[c.skill].available += c.hours;
}
this.setPortValue("pool-out", { totalHours, bySkill, count: commitments.length });
} }
// ── Canvas coord helpers ── // ── Canvas coord helpers ──

View File

@ -67,4 +67,91 @@ const dueToday: AppletDefinition = {
}, },
}; };
export const tasksApplets: AppletDefinition[] = [taskCounter, dueToday]; // ── Resource Coverage ──
const SKILL_COLORS: Record<string, string> = {
facilitation: '#8b5cf6', design: '#ec4899', tech: '#3b82f6',
outreach: '#10b981', logistics: '#f59e0b',
};
interface CoverageTask {
id: string;
title: string;
needs: Record<string, number>;
fulfilled: Record<string, number>;
ready: boolean;
partial: boolean;
}
const resourceCoverage: AppletDefinition = {
id: "resource-coverage",
label: "Resource Coverage",
icon: "🎯",
accentColor: "#0f766e",
ports: [
{ name: "coverage-in", type: "json", direction: "input" },
{ name: "gaps-out", type: "json", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const tasks = (snapshot.tasks as CoverageTask[]) || [];
const summary = (snapshot.summary as { total: number; ready: number; partial: number }) || { total: 0, ready: 0, partial: 0 };
if (tasks.length === 0) {
return `<div style="text-align:center;color:#64748b;font-style:italic;padding:16px 0">Connect coverage-in to see task status</div>`;
}
const unresourced = summary.total - summary.ready - summary.partial;
let html = `<div style="display:flex;gap:6px;justify-content:center;margin-bottom:8px;font-size:10px;font-weight:600">
<span style="color:#22c55e">${summary.ready} ready</span>
<span style="color:#94a3b8">&middot;</span>
<span style="color:#f59e0b">${summary.partial} partial</span>
<span style="color:#94a3b8">&middot;</span>
<span style="color:#ef4444">${unresourced} unresourced</span>
</div>`;
for (const t of tasks.slice(0, 6)) {
const pillColor = t.ready ? '#22c55e' : t.partial ? '#f59e0b' : '#ef4444';
const pillLabel = t.ready ? 'ready' : t.partial ? 'partial' : 'needs resources';
const skillTags = Object.keys(t.needs).map(sk => {
const got = t.fulfilled?.[sk] || 0;
const need = t.needs[sk];
const filled = got >= need;
const color = SKILL_COLORS[sk] || '#6b7280';
return `<span style="display:inline-block;padding:0 4px;border-radius:3px;font-size:8px;margin-right:2px;background:${filled ? color + '30' : '#1e293b'};color:${filled ? color : '#64748b'};border:1px solid ${filled ? color + '50' : '#334155'}">${sk} ${got}/${need}</span>`;
}).join('');
html += `<div style="margin-bottom:4px;padding:3px 6px;background:#0f172a;border-radius:4px;display:flex;align-items:center;gap:6px">
<span style="display:inline-block;padding:1px 5px;border-radius:8px;font-size:8px;font-weight:600;background:${pillColor}20;color:${pillColor};border:1px solid ${pillColor}40;white-space:nowrap">${pillLabel}</span>
<div style="flex:1;min-width:0">
<div style="font-size:10px;font-weight:600;color:#e2e8f0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${t.title}</div>
<div style="margin-top:1px">${skillTags}</div>
</div>
</div>`;
}
if (tasks.length > 6) {
html += `<div style="font-size:9px;color:#64748b;text-align:center">+${tasks.length - 6} more</div>`;
}
return html;
},
onInputReceived(portName, value, ctx) {
if (portName === "coverage-in" && value && typeof value === "object") {
const coverage = value as { tasks?: CoverageTask[]; summary?: Record<string, number> };
// Compute gaps — skills with unfilled needs
const gaps: Array<{ taskId: string; taskTitle: string; skill: string; needed: number; have: number }> = [];
for (const t of coverage.tasks || []) {
for (const [sk, need] of Object.entries(t.needs)) {
const have = t.fulfilled?.[sk] || 0;
if (have < need) {
gaps.push({ taskId: t.id, taskTitle: t.title, skill: sk, needed: need, have });
}
}
}
ctx.emitOutput("gaps-out", gaps);
}
},
};
export const tasksApplets: AppletDefinition[] = [taskCounter, dueToday, resourceCoverage];

View File

@ -876,6 +876,7 @@ export const tasksModule: RSpaceModule = {
publicWrite: true, publicWrite: true,
description: "Kanban workspace boards for collaborative task management", description: "Kanban workspace boards for collaborative task management",
scoping: { defaultScope: 'space', userConfigurable: false }, scoping: { defaultScope: 'space', userConfigurable: false },
canvasToolIds: ["create_resource_coverage"],
docSchemas: [ docSchemas: [
{ pattern: '{space}:tasks:boards:{boardId}', description: 'Kanban board with tasks', init: boardSchema.init }, { pattern: '{space}:tasks:boards:{boardId}', description: 'Kanban board with tasks', init: boardSchema.init },
{ pattern: '{space}:tasks:clickup-connection', description: 'ClickUp integration credentials', init: clickupConnectionSchema.init }, { pattern: '{space}:tasks:clickup-connection', description: 'ClickUp integration credentials', init: clickupConnectionSchema.init },

View File

@ -1,8 +1,9 @@
/** /**
* rTime applet definitions Commitment Meter. * rTime applet definitions Commitment Meter + Weaving Coverage.
*/ */
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types"; import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
import { getModuleApiBase } from "../../shared/url-helpers";
const commitmentMeter: AppletDefinition = { const commitmentMeter: AppletDefinition = {
id: "commitment-meter", id: "commitment-meter",
@ -39,4 +40,125 @@ const commitmentMeter: AppletDefinition = {
}, },
}; };
export const timeApplets: AppletDefinition[] = [commitmentMeter]; // ── Weaving Coverage ──
interface WeavePlacedTask {
id: string;
title: string;
status: string;
needs: Record<string, number>;
}
interface WeaveData {
placedTasks?: WeavePlacedTask[];
connections?: Array<{ commitmentId: string; taskId: string; skill: string; hours: number }>;
}
const SKILL_COLORS: Record<string, string> = {
facilitation: '#8b5cf6', design: '#ec4899', tech: '#3b82f6',
outreach: '#10b981', logistics: '#f59e0b',
};
const weavingCoverage: AppletDefinition = {
id: "weaving-coverage",
label: "Weaving Coverage",
icon: "🧶",
accentColor: "#7c3aed",
ports: [
{ name: "pool-in", type: "json", direction: "input" },
{ name: "coverage-out", type: "json", direction: "output" },
],
async fetchLiveData(space: string): Promise<Record<string, unknown>> {
try {
const resp = await fetch(`${getModuleApiBase("rtime")}/api/weave`);
if (!resp.ok) return {};
const data: WeaveData = await resp.json();
return buildCoverageSnapshot(data);
} catch { return {}; }
},
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const tasks = (snapshot.tasks as Array<{ id: string; title: string; ready: boolean; partial: boolean; needs: Record<string, number>; fulfilled: Record<string, number> }>) || [];
const summary = (snapshot.summary as { total: number; ready: number; partial: number }) || { total: 0, ready: 0, partial: 0 };
if (tasks.length === 0) {
return `<div style="text-align:center;color:#64748b;font-style:italic;padding:16px 0">No tasks on weaving canvas</div>`;
}
let html = `<div style="font-size:10px;color:#94a3b8;margin-bottom:6px">${summary.ready}/${summary.total} tasks resourced</div>`;
for (const t of tasks.slice(0, 5)) {
const skills = Object.keys(t.needs);
let bars = '';
for (const sk of skills) {
const need = t.needs[sk] || 1;
const got = (t.fulfilled?.[sk]) || 0;
const pct = Math.min(100, Math.round((got / need) * 100));
const color = SKILL_COLORS[sk] || '#6b7280';
bars += `<div style="display:flex;align-items:center;gap:4px;margin:1px 0">
<span style="width:50px;font-size:8px;color:${color};text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${sk}</span>
<div style="flex:1;background:#1e293b;border-radius:2px;height:4px;overflow:hidden">
<div style="background:${color};width:${pct}%;height:100%;border-radius:2px"></div>
</div>
<span style="font-size:8px;color:#64748b;width:24px">${got}/${need}</span>
</div>`;
}
const statusColor = t.ready ? '#22c55e' : t.partial ? '#f59e0b' : '#64748b';
html += `<div style="margin-bottom:6px;padding:4px 6px;background:#0f172a;border-radius:4px;border-left:2px solid ${statusColor}">
<div style="font-size:10px;font-weight:600;color:#e2e8f0;margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${t.title}</div>
${bars}
</div>`;
}
if (tasks.length > 5) {
html += `<div style="font-size:9px;color:#64748b;text-align:center">+${tasks.length - 5} more</div>`;
}
return html;
},
onInputReceived(portName, value, ctx) {
if (portName === "pool-in") {
// Pool data received — re-fetch would be ideal but for now just trigger output
// The fetchLiveData polling handles refresh
}
},
};
function buildCoverageSnapshot(data: WeaveData): Record<string, unknown> {
const placed = data.placedTasks || [];
const connections = data.connections || [];
// Build fulfillment map: taskId → skill → hours fulfilled
const fulfillment = new Map<string, Record<string, number>>();
for (const conn of connections) {
if (!fulfillment.has(conn.taskId)) fulfillment.set(conn.taskId, {});
const tf = fulfillment.get(conn.taskId)!;
tf[conn.skill] = (tf[conn.skill] || 0) + conn.hours;
}
const tasks = placed.map(t => {
const needs = t.needs || {};
const fulfilled = fulfillment.get(t.id) || {};
const skills = Object.keys(needs);
const allFilled = skills.length > 0 && skills.every(sk => (fulfilled[sk] || 0) >= needs[sk]);
const someFilled = skills.some(sk => (fulfilled[sk] || 0) > 0);
return {
id: t.id,
title: t.title,
needs,
fulfilled,
ready: allFilled,
partial: !allFilled && someFilled,
};
});
const ready = tasks.filter(t => t.ready).length;
const partial = tasks.filter(t => t.partial).length;
return {
tasks,
summary: { total: tasks.length, ready, partial },
};
}
export const timeApplets: AppletDefinition[] = [commitmentMeter, weavingCoverage];

View File

@ -1228,7 +1228,7 @@ export const timeModule: RSpaceModule = {
icon: "⏳", icon: "⏳",
description: "Timebank commitment pool & weaving dashboard", description: "Timebank commitment pool & weaving dashboard",
canvasShapes: ["folk-commitment-pool", "folk-task-request"], canvasShapes: ["folk-commitment-pool", "folk-task-request"],
canvasToolIds: ["create_commitment_pool", "create_task_request"], canvasToolIds: ["create_commitment_pool", "create_task_request", "create_weaving_coverage"],
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 },

View File

@ -46,6 +46,8 @@ export interface AppletDefinition {
getCircuit?(space: string): { nodes: AppletSubNode[]; edges: AppletSubEdge[] }; getCircuit?(space: string): { nodes: AppletSubNode[]; edges: AppletSubEdge[] };
/** Optional: handle data arriving on an input port */ /** Optional: handle data arriving on an input port */
onInputReceived?(portName: string, value: unknown, ctx: AppletContext): void; onInputReceived?(portName: string, value: unknown, ctx: AppletContext): void;
/** Optional: self-fetch live data on init + interval (called every 30s) */
fetchLiveData?(space: string): Promise<Record<string, unknown>>;
} }
// ── Runtime data ── // ── Runtime data ──