rspace-online/modules/rtime/applets.ts

165 lines
5.9 KiB
TypeScript

/**
* 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",
label: "Commitment Meter",
icon: "⏳",
accentColor: "#7c3aed",
ports: [
{ name: "pool-in", type: "json", direction: "input" },
{ name: "committed-out", type: "number", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const committed = (snapshot.committed as number) || 0;
const capacity = (snapshot.capacity as number) || 1;
const pct = Math.min(100, Math.round((committed / capacity) * 100));
const label = (snapshot.poolName as string) || "Pool";
return `
<div style="text-align:center">
<div style="font-size:13px;font-weight:600;margin-bottom:6px">${label}</div>
<div style="font-size:24px;font-weight:700;color:#e2e8f0">${pct}%</div>
<div style="font-size:10px;color:#94a3b8;margin:4px 0 8px">${committed}/${capacity} hrs</div>
<div style="background:#334155;border-radius:3px;height:6px;overflow:hidden">
<div style="background:#7c3aed;width:${pct}%;height:100%;border-radius:3px;transition:width 0.3s"></div>
</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "pool-in" && value && typeof value === "object") {
const pool = value as Record<string, unknown>;
ctx.emitOutput("committed-out", Number(pool.committed) || 0);
}
},
};
// ── 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];