rspace-online/lib/folk-task-request.ts

440 lines
13 KiB
TypeScript

/**
* folk-task-request — Canvas shape representing a task card with skill slots.
* Acts as a drop target for orbs dragged from folk-commitment-pool.
*
* On drop: POST connection to rTime API + dispatch notification event.
*/
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
// Skill constants (mirrored from rtime/schemas)
const SKILL_COLORS: Record<string, string> = {
facilitation: '#8b5cf6', design: '#ec4899', tech: '#3b82f6',
outreach: '#10b981', logistics: '#f59e0b',
};
const SKILL_LABELS: Record<string, string> = {
facilitation: 'Facilitation', design: 'Design', tech: 'Tech',
outreach: 'Outreach', logistics: 'Logistics',
};
const styles = css`
:host {
background: #1e293b;
border-radius: 10px;
border: 1.5px solid #334155;
overflow: hidden;
}
:host(.drag-highlight) {
border-color: #14b8a6;
box-shadow: 0 0 16px rgba(20, 184, 166, 0.3);
}
.header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px 6px;
cursor: move;
}
.header .icon { font-size: 16px; }
.task-name {
flex: 1;
font-size: 13px;
font-weight: 600;
color: #e2e8f0;
background: none;
border: none;
outline: none;
font-family: inherit;
padding: 0;
}
.task-name:focus {
border-bottom: 1px solid #14b8a6;
}
.task-name::placeholder { color: #64748b; }
.desc {
padding: 0 12px 8px;
font-size: 11px;
color: #94a3b8;
line-height: 1.4;
}
.desc-input {
width: 100%;
background: none;
border: none;
outline: none;
color: #94a3b8;
font-size: 11px;
font-family: inherit;
padding: 0;
resize: none;
}
.desc-input::placeholder { color: #475569; }
.skill-slots {
padding: 0 12px 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.slot {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
border-radius: 6px;
background: #0f172a;
border: 1.5px dashed #334155;
font-size: 11px;
color: #cbd5e1;
transition: border-color 0.2s, background 0.2s;
}
.slot.highlight {
border-color: #14b8a6;
background: rgba(20, 184, 166, 0.08);
}
.slot.filled {
border-style: solid;
opacity: 0.7;
}
.slot-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.slot-label { flex: 1; }
.slot-hours {
font-weight: 600;
font-size: 10px;
color: #64748b;
}
.slot-assigned {
font-size: 10px;
color: #14b8a6;
font-weight: 500;
}
`;
interface SlotState {
skill: string;
hoursNeeded: number;
assignedMember?: string;
commitmentId?: string;
}
declare global {
interface HTMLElementTagNameMap {
"folk-task-request": FolkTaskRequest;
}
}
export class FolkTaskRequest extends FolkShape {
static override tagName = "folk-task-request";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n");
const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#taskId = "";
#spaceSlug = "demo";
#taskName = "New Task";
#taskDescription = "";
#needs: Record<string, number> = {};
#slots: SlotState[] = [];
#wrapper!: HTMLElement;
#slotsContainer!: HTMLElement;
#nameInput!: HTMLInputElement;
#descInput!: HTMLTextAreaElement;
get taskId() { return this.#taskId; }
set taskId(v: string) { this.#taskId = v; }
get spaceSlug() { return this.#spaceSlug; }
set spaceSlug(v: string) { this.#spaceSlug = v; }
get taskName() { return this.#taskName; }
set taskName(v: string) {
this.#taskName = v;
if (this.#nameInput) this.#nameInput.value = v;
this.dispatchEvent(new CustomEvent("content-change"));
}
get taskDescription() { return this.#taskDescription; }
set taskDescription(v: string) {
this.#taskDescription = v;
if (this.#descInput) this.#descInput.value = v;
this.dispatchEvent(new CustomEvent("content-change"));
}
get needs() { return this.#needs; }
set needs(v: Record<string, number>) {
this.#needs = v;
this.#buildSlots();
this.dispatchEvent(new CustomEvent("content-change"));
}
override createRenderRoot() {
const root = super.createRenderRoot();
this.#wrapper = document.createElement("div");
this.#wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;";
this.#wrapper.innerHTML = html`
<div class="header">
<span class="icon">📋</span>
<input class="task-name" type="text" placeholder="Task name..." />
</div>
<div class="desc">
<textarea class="desc-input" rows="2" placeholder="Description..."></textarea>
</div>
<div class="skill-slots"></div>
`;
const slot = root.querySelector("slot");
const container = slot?.parentElement as HTMLElement;
if (container) container.replaceWith(this.#wrapper);
this.#nameInput = this.#wrapper.querySelector(".task-name") as HTMLInputElement;
this.#descInput = this.#wrapper.querySelector(".desc-input") as HTMLTextAreaElement;
this.#slotsContainer = this.#wrapper.querySelector(".skill-slots") as HTMLElement;
this.#nameInput.value = this.#taskName;
this.#descInput.value = this.#taskDescription;
// Stop events from triggering shape move
const stopProp = (e: Event) => e.stopPropagation();
this.#nameInput.addEventListener("click", stopProp);
this.#descInput.addEventListener("click", stopProp);
this.#nameInput.addEventListener("pointerdown", stopProp);
this.#descInput.addEventListener("pointerdown", stopProp);
this.#nameInput.addEventListener("change", () => {
this.#taskName = this.#nameInput.value.trim() || "New Task";
this.dispatchEvent(new CustomEvent("content-change"));
});
this.#nameInput.addEventListener("keydown", (e) => {
e.stopPropagation();
if (e.key === "Enter") this.#nameInput.blur();
});
this.#descInput.addEventListener("change", () => {
this.#taskDescription = this.#descInput.value.trim();
this.dispatchEvent(new CustomEvent("content-change"));
});
this.#descInput.addEventListener("keydown", (e) => {
e.stopPropagation();
});
this.#buildSlots();
// Listen for commitment drag events
document.addEventListener("commitment-drag-start", this.#onDragStart as EventListener);
document.addEventListener("commitment-drag-move", this.#onDragMove as EventListener);
document.addEventListener("commitment-drag-end", this.#onDragEnd as EventListener);
return root;
}
disconnectedCallback() {
document.removeEventListener("commitment-drag-start", this.#onDragStart as EventListener);
document.removeEventListener("commitment-drag-move", this.#onDragMove as EventListener);
document.removeEventListener("commitment-drag-end", this.#onDragEnd as EventListener);
}
#buildSlots() {
if (!this.#slotsContainer) return;
// Build slots from needs
this.#slots = Object.entries(this.#needs).map(([skill, hours]) => {
// Preserve existing assignment if any
const existing = this.#slots.find(s => s.skill === skill);
return {
skill,
hoursNeeded: hours,
assignedMember: existing?.assignedMember,
commitmentId: existing?.commitmentId,
};
});
this.#renderSlots();
}
#renderSlots() {
if (!this.#slotsContainer) return;
this.#slotsContainer.innerHTML = "";
for (const slot of this.#slots) {
const el = document.createElement("div");
el.className = "slot" + (slot.assignedMember ? " filled" : "");
el.dataset.skill = slot.skill;
el.innerHTML = `
<span class="slot-dot" style="background:${SKILL_COLORS[slot.skill] || '#64748b'}"></span>
<span class="slot-label">${SKILL_LABELS[slot.skill] || slot.skill}</span>
<span class="slot-hours">${slot.hoursNeeded}hr</span>
${slot.assignedMember ? `<span class="slot-assigned">${slot.assignedMember}</span>` : ""}
`;
this.#slotsContainer.appendChild(el);
}
}
// ── Drag handling ──
#activeDragSkill: string | null = null;
#onDragStart = (e: CustomEvent) => {
this.#activeDragSkill = e.detail.skill;
// Highlight matching unfilled slots
const matchingSlots = this.#slots.filter(s => s.skill === this.#activeDragSkill && !s.assignedMember);
if (matchingSlots.length > 0) {
this.classList.add("drag-highlight");
this.#highlightSlots(this.#activeDragSkill!);
}
};
#onDragMove = (e: CustomEvent) => {
// Check if pointer is over this shape
const rect = this.getBoundingClientRect();
const over = e.detail.clientX >= rect.left && e.detail.clientX <= rect.right
&& e.detail.clientY >= rect.top && e.detail.clientY <= rect.bottom;
if (over && this.#activeDragSkill) {
this.classList.add("drag-highlight");
this.#highlightSlots(this.#activeDragSkill);
} else {
this.classList.remove("drag-highlight");
this.#clearSlotHighlights();
}
};
#onDragEnd = (e: CustomEvent) => {
this.classList.remove("drag-highlight");
this.#clearSlotHighlights();
if (!this.#activeDragSkill) return;
const skill = this.#activeDragSkill;
this.#activeDragSkill = null;
// Check if drop landed on this shape
const rect = this.getBoundingClientRect();
const over = e.detail.clientX >= rect.left && e.detail.clientX <= rect.right
&& e.detail.clientY >= rect.top && e.detail.clientY <= rect.bottom;
if (!over) return;
// Find matching unfilled slot
const slot = this.#slots.find(s => s.skill === skill && !s.assignedMember);
if (!slot) return;
// Assign
slot.assignedMember = e.detail.memberName;
slot.commitmentId = e.detail.commitmentId;
this.#renderSlots();
// POST connection
this.#postConnection(e.detail.commitmentId, skill);
this.dispatchEvent(new CustomEvent("commitment-assigned", {
bubbles: true,
detail: {
taskId: this.#taskId,
commitmentId: e.detail.commitmentId,
skill,
},
}));
};
#highlightSlots(skill: string) {
const slotEls = this.#slotsContainer.querySelectorAll(".slot");
slotEls.forEach(el => {
const s = (el as HTMLElement).dataset.skill;
if (s === skill && !el.classList.contains("filled")) {
el.classList.add("highlight");
}
});
}
#clearSlotHighlights() {
this.#slotsContainer.querySelectorAll(".slot.highlight").forEach(el => el.classList.remove("highlight"));
}
async #postConnection(commitmentId: string, skill: string) {
if (!this.#taskId) return;
try {
// Get auth token from cookie or localStorage
const token = document.cookie.split(";").map(c => c.trim()).find(c => c.startsWith("auth_token="))?.split("=")[1]
|| localStorage.getItem("auth_token") || "";
await fetch(`/${this.#spaceSlug}/rtime/api/connections`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { "Authorization": `Bearer ${token}` } : {}),
},
body: JSON.stringify({
fromCommitmentId: commitmentId,
toTaskId: this.#taskId,
skill,
}),
});
} catch { /* best-effort */ }
}
// ── Serialization ──
override toJSON() {
const slotsData: Record<string, { member?: string; commitmentId?: string }> = {};
for (const s of this.#slots) {
if (s.assignedMember) {
slotsData[s.skill] = { member: s.assignedMember, commitmentId: s.commitmentId };
}
}
return {
...super.toJSON(),
type: "folk-task-request",
taskId: this.#taskId,
spaceSlug: this.#spaceSlug,
taskName: this.#taskName,
taskDescription: this.#taskDescription,
needs: this.#needs,
assignments: slotsData,
};
}
static override fromData(data: Record<string, any>): FolkTaskRequest {
const shape = FolkShape.fromData(data) as FolkTaskRequest;
if (data.taskId) shape.taskId = data.taskId;
if (data.spaceSlug) shape.spaceSlug = data.spaceSlug;
if (data.taskName) shape.taskName = data.taskName;
if (data.taskDescription) shape.taskDescription = data.taskDescription;
if (data.needs) shape.needs = data.needs;
// Restore assignments
if (data.assignments) {
for (const [skill, info] of Object.entries(data.assignments as Record<string, any>)) {
const slot = shape.#slots.find(s => s.skill === skill);
if (slot && info.member) {
slot.assignedMember = info.member;
slot.commitmentId = info.commitmentId;
}
}
shape.#renderSlots();
}
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.taskId && data.taskId !== this.#taskId) this.taskId = data.taskId;
if (data.spaceSlug && data.spaceSlug !== this.#spaceSlug) this.spaceSlug = data.spaceSlug;
if (data.taskName !== undefined && data.taskName !== this.#taskName) this.taskName = data.taskName;
if (data.taskDescription !== undefined && data.taskDescription !== this.#taskDescription) this.taskDescription = data.taskDescription;
if (data.needs && JSON.stringify(data.needs) !== JSON.stringify(this.#needs)) this.needs = data.needs;
if (data.assignments) {
for (const [skill, info] of Object.entries(data.assignments as Record<string, any>)) {
const slot = this.#slots.find(s => s.skill === skill);
if (slot && info.member) {
slot.assignedMember = info.member;
slot.commitmentId = info.commitmentId;
}
}
this.#renderSlots();
}
}
}