Compare commits
2 Commits
4097a5eeac
...
7214599f5a
| Author | SHA1 | Date |
|---|---|---|
|
|
7214599f5a | |
|
|
72587ef690 |
|
|
@ -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 ──
|
||||
registry.push({
|
||||
declaration: {
|
||||
|
|
|
|||
|
|
@ -205,6 +205,9 @@ export class FolkApplet extends FolkShape {
|
|||
// Instance-level port descriptors (override static)
|
||||
#instancePorts: PortDescriptor[] = [];
|
||||
|
||||
// Live data polling timer
|
||||
#liveDataTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
get moduleId() { return this.#moduleId; }
|
||||
set moduleId(v: string) {
|
||||
this.#moduleId = v;
|
||||
|
|
@ -333,9 +336,35 @@ export class FolkApplet extends FolkShape {
|
|||
detail: { moduleId: this.#moduleId, appletId: this.#appletId, shapeId: this.id },
|
||||
}));
|
||||
|
||||
// Start self-fetch polling if the applet defines fetchLiveData
|
||||
this.#startLiveDataPolling();
|
||||
|
||||
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 {
|
||||
this.#wrapper.querySelectorAll(".port-chip").forEach(el => el.remove());
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import { FolkShape } from "./folk-shape";
|
||||
import { css, html } from "./tags";
|
||||
import { getModuleApiBase } from "../shared/url-helpers";
|
||||
import type { PortDescriptor } from "./data-types";
|
||||
|
||||
// ── Skill constants (mirrored from rtime/schemas to avoid server import) ──
|
||||
|
||||
|
|
@ -195,6 +196,10 @@ declare global {
|
|||
export class FolkCommitmentPool extends FolkShape {
|
||||
static override tagName = "folk-commitment-pool";
|
||||
|
||||
static override portDescriptors: PortDescriptor[] = [
|
||||
{ name: "pool-out", type: "json", direction: "output" },
|
||||
];
|
||||
|
||||
static {
|
||||
const sheet = new CSSStyleSheet();
|
||||
const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n");
|
||||
|
|
@ -265,6 +270,7 @@ export class FolkCommitmentPool extends FolkShape {
|
|||
|
||||
override createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
this.initPorts();
|
||||
|
||||
this.#wrapper = document.createElement("div");
|
||||
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);
|
||||
});
|
||||
|
||||
// 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 ──
|
||||
|
|
|
|||
|
|
@ -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">·</span>
|
||||
<span style="color:#f59e0b">${summary.partial} partial</span>
|
||||
<span style="color:#94a3b8">·</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];
|
||||
|
|
|
|||
|
|
@ -876,6 +876,7 @@ export const tasksModule: RSpaceModule = {
|
|||
publicWrite: true,
|
||||
description: "Kanban workspace boards for collaborative task management",
|
||||
scoping: { defaultScope: 'space', userConfigurable: false },
|
||||
canvasToolIds: ["create_resource_coverage"],
|
||||
docSchemas: [
|
||||
{ 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 },
|
||||
|
|
|
|||
|
|
@ -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 { getModuleApiBase } from "../../shared/url-helpers";
|
||||
|
||||
const commitmentMeter: AppletDefinition = {
|
||||
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];
|
||||
|
|
|
|||
|
|
@ -1228,7 +1228,7 @@ export const timeModule: RSpaceModule = {
|
|||
icon: "⏳",
|
||||
description: "Timebank commitment pool & weaving dashboard",
|
||||
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 },
|
||||
docSchemas: [
|
||||
{ pattern: '{space}:rtime:commitments', description: 'Commitment pool', init: commitmentsSchema.init },
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ export interface AppletDefinition {
|
|||
getCircuit?(space: string): { nodes: AppletSubNode[]; edges: AppletSubEdge[] };
|
||||
/** Optional: handle data arriving on an input port */
|
||||
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 ──
|
||||
|
|
|
|||
Loading…
Reference in New Issue