nanobot-voice-interface/examples/cards/templates/todo-item-live/card.js

709 lines
22 KiB
JavaScript
Raw Normal View History

const TASK_LANES = ["backlog", "committed", "in-progress", "blocked", "done", "canceled"];
const TASK_ACTION_LABELS = {
backlog: "Backlog",
committed: "Commit",
"in-progress": "Start",
blocked: "Block",
done: "Done",
canceled: "Cancel",
};
const TASK_LANE_LABELS = {
backlog: "Backlog",
committed: "Committed",
"in-progress": "In Progress",
blocked: "Blocked",
done: "Done",
canceled: "Canceled",
};
const TASK_LANE_THEMES = {
backlog: {
accent: "#5f7884",
accentSoft: "rgba(95, 120, 132, 0.13)",
muted: "#6b7e87",
buttonInk: "#294a57",
},
committed: {
accent: "#8a6946",
accentSoft: "rgba(138, 105, 70, 0.14)",
muted: "#7f664a",
buttonInk: "#5a3b19",
},
"in-progress": {
accent: "#4f7862",
accentSoft: "rgba(79, 120, 98, 0.13)",
muted: "#5e7768",
buttonInk: "#214437",
},
blocked: {
accent: "#a55f4b",
accentSoft: "rgba(165, 95, 75, 0.13)",
muted: "#906659",
buttonInk: "#6c2f21",
},
done: {
accent: "#6d7f58",
accentSoft: "rgba(109, 127, 88, 0.12)",
muted: "#6b755d",
buttonInk: "#304121",
},
canceled: {
accent: "#7b716a",
accentSoft: "rgba(123, 113, 106, 0.12)",
muted: "#7b716a",
buttonInk: "#433932",
},
};
function isTaskLane(value) {
return typeof value === "string" && TASK_LANES.includes(value);
}
function normalizeTag(raw) {
const trimmed = String(raw || "")
.trim()
.replace(/^#+/, "")
.replace(/\s+/g, "-");
return trimmed ? `#${trimmed}` : "";
}
function normalizeTags(raw) {
if (!Array.isArray(raw)) return [];
const seen = new Set();
const tags = [];
for (const value of raw) {
const tag = normalizeTag(value);
const key = tag.toLowerCase();
if (!tag || seen.has(key)) continue;
seen.add(key);
tags.push(tag);
}
return tags;
}
function normalizeMetadata(raw) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const entries = Object.entries(raw).filter(([, value]) => value !== undefined);
return Object.fromEntries(entries);
}
function normalizeTask(raw, fallbackTitle) {
const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
const lane = isTaskLane(record.lane) ? record.lane : "backlog";
return {
taskPath: typeof record.task_path === "string" ? record.task_path.trim() : "",
taskKey: typeof record.task_key === "string" ? record.task_key.trim() : "",
title:
typeof record.title === "string" && record.title.trim()
? record.title.trim()
: fallbackTitle || "(Untitled task)",
lane,
created: typeof record.created === "string" ? record.created.trim() : "",
updated: typeof record.updated === "string" ? record.updated.trim() : "",
due: typeof record.due === "string" ? record.due.trim() : "",
tags: normalizeTags(record.tags),
body: typeof record.body === "string" ? record.body : "",
metadata: normalizeMetadata(record.metadata),
};
}
function normalizeTaskFromPayload(raw, fallback) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return fallback;
return {
taskPath: typeof raw.path === "string" ? raw.path.trim() : fallback.taskPath,
taskKey: fallback.taskKey,
title:
typeof raw.title === "string" && raw.title.trim()
? raw.title.trim()
: fallback.title,
lane: isTaskLane(raw.lane) ? raw.lane : fallback.lane,
created: typeof raw.created === "string" ? raw.created.trim() : fallback.created,
updated: typeof raw.updated === "string" ? raw.updated.trim() : fallback.updated,
due: typeof raw.due === "string" ? raw.due.trim() : fallback.due,
tags: normalizeTags(raw.tags),
body: typeof raw.body === "string" ? raw.body : fallback.body,
metadata: normalizeMetadata(raw.metadata),
};
}
function parseToolPayload(result) {
if (result && typeof result === "object" && result.parsed && typeof result.parsed === "object") {
return result.parsed;
}
const raw = typeof result?.content === "string" ? result.content : "";
if (!raw.trim()) return null;
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
function dueScore(hoursUntilDue) {
if (hoursUntilDue <= 0) return 100;
if (hoursUntilDue <= 6) return 96;
if (hoursUntilDue <= 24) return 92;
if (hoursUntilDue <= 72) return 82;
if (hoursUntilDue <= 168) return 72;
return 62;
}
function ageScore(ageDays) {
if (ageDays >= 30) return 80;
if (ageDays >= 21) return 76;
if (ageDays >= 14) return 72;
if (ageDays >= 7) return 68;
if (ageDays >= 3) return 62;
if (ageDays >= 1) return 58;
return 54;
}
function computeTaskScore(task) {
const now = Date.now();
const rawDue = task.due ? (task.due.includes("T") ? task.due : `${task.due}T12:00:00`) : "";
const dueMs = rawDue ? new Date(rawDue).getTime() : Number.NaN;
let score = 54;
if (Number.isFinite(dueMs)) {
score = dueScore((dueMs - now) / (60 * 60 * 1000));
} else {
const createdMs = task.created ? new Date(task.created).getTime() : Number.NaN;
if (Number.isFinite(createdMs)) {
score = ageScore(Math.max(0, (now - createdMs) / (24 * 60 * 60 * 1000)));
}
}
if (task.lane === "committed") return Math.min(100, score + 1);
if (task.lane === "blocked") return Math.min(100, score + 4);
if (task.lane === "in-progress") return Math.min(100, score + 2);
return score;
}
function summarizeTaskBody(task) {
const trimmed = String(task.body || "").trim();
if (!trimmed || /^##\s+Imported\b/i.test(trimmed)) return "";
return trimmed;
}
function renderTaskBodyMarkdown(host, body) {
if (!body) return "";
return body
.replace(/\r\n?/g, "\n")
.trim()
.split("\n")
.map((line) => {
const trimmed = line.trim();
if (!trimmed) return '<span class="task-card-ui__md-break" aria-hidden="true"></span>';
let className = "task-card-ui__md-line";
let content = trimmed;
let prefix = "";
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
className += " task-card-ui__md-line--heading";
content = headingMatch[2];
} else if (/^[-*]\s+/.test(trimmed)) {
className += " task-card-ui__md-line--bullet";
content = trimmed.replace(/^[-*]\s+/, "");
prefix = "\u2022 ";
} else if (/^\d+\.\s+/.test(trimmed)) {
className += " task-card-ui__md-line--bullet";
content = trimmed.replace(/^\d+\.\s+/, "");
prefix = "\u2022 ";
} else if (/^>\s+/.test(trimmed)) {
className += " task-card-ui__md-line--quote";
content = trimmed.replace(/^>\s+/, "");
prefix = "> ";
}
const html = host.renderMarkdown(content, { inline: true });
return `<span class="${className}">${
prefix ? `<span class="task-card-ui__md-prefix">${prefix}</span>` : ""
}${html}</span>`;
})
.join("");
}
function formatTaskDue(task) {
if (!task.due) return "";
const raw = task.due.includes("T") ? task.due : `${task.due}T00:00:00`;
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return task.due;
if (task.due.includes("T")) {
const label = parsed.toLocaleString([], {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
return label.replace(/\s([AP]M)$/i, "$1");
}
return parsed.toLocaleDateString([], { month: "short", day: "numeric" });
}
function taskMoveOptions(lane) {
return TASK_LANES.filter((targetLane) => targetLane !== lane).map((targetLane) => ({
lane: targetLane,
label: TASK_ACTION_LABELS[targetLane],
}));
}
function taskLiveContent(task, errorText) {
return {
kind: "file_task",
exists: true,
task_path: task.taskPath || null,
task_key: task.taskKey || null,
title: task.title || null,
lane: task.lane,
created: task.created || null,
updated: task.updated || null,
due: task.due || null,
tags: task.tags,
metadata: task.metadata,
score: computeTaskScore(task),
status: task.lane,
error: errorText || null,
};
}
function autosizeEditor(editor) {
editor.style.height = "0px";
editor.style.height = `${Math.max(editor.scrollHeight, 20)}px`;
}
export function mount({ root, item, state, host }) {
const cardEl = root.querySelector(".task-card-ui");
const laneToggleEl = root.querySelector(".task-card-ui__lane-button");
const laneWrapEl = root.querySelector(".task-card-ui__lane-wrap");
const laneMenuEl = root.querySelector(".task-card-ui__lane-menu");
const statusEl = root.querySelector(".task-card-ui__status");
const titleEl = root.querySelector(".task-card-ui__title-slot");
const tagsEl = root.querySelector(".task-card-ui__tags");
const bodyEl = root.querySelector(".task-card-ui__body-slot");
const metaEl = root.querySelector(".task-card-ui__meta");
const dueEl = root.querySelector(".task-card-ui__chip");
if (
!(cardEl instanceof HTMLElement) ||
!(laneToggleEl instanceof HTMLButtonElement) ||
!(laneWrapEl instanceof HTMLElement) ||
!(laneMenuEl instanceof HTMLElement) ||
!(statusEl instanceof HTMLElement) ||
!(titleEl instanceof HTMLElement) ||
!(tagsEl instanceof HTMLElement) ||
!(bodyEl instanceof HTMLElement) ||
!(metaEl instanceof HTMLElement) ||
!(dueEl instanceof HTMLElement)
) {
return;
}
let task = normalizeTask(state, item.title);
let busy = false;
let errorText = "";
let statusLabel = "";
let statusKind = "neutral";
let laneMenuOpen = false;
let editingField = null;
let holdTimer = null;
const clearHoldTimer = () => {
if (holdTimer !== null) {
window.clearTimeout(holdTimer);
holdTimer = null;
}
};
const setStatus = (label, kind) => {
statusLabel = label || "";
statusKind = kind || "neutral";
statusEl.textContent = statusLabel;
statusEl.className = `task-card-ui__status${statusKind === "error" ? " is-error" : ""}`;
};
const publishLiveContent = () => {
host.setLiveContent(taskLiveContent(task, errorText));
};
const closeLaneMenu = () => {
laneMenuOpen = false;
laneToggleEl.setAttribute("aria-expanded", "false");
laneMenuEl.style.display = "none";
};
const openLaneMenu = () => {
if (busy || !task.taskPath) return;
laneMenuOpen = true;
laneToggleEl.setAttribute("aria-expanded", "true");
laneMenuEl.style.display = "flex";
};
const refreshFeed = () => {
closeLaneMenu();
host.requestFeedRefresh();
};
const setBusy = (nextBusy) => {
busy = !!nextBusy;
laneToggleEl.disabled = busy || !task.taskPath;
titleEl.style.pointerEvents = busy ? "none" : "";
bodyEl.style.pointerEvents = busy ? "none" : "";
tagsEl
.querySelectorAll("button")
.forEach((button) => (button.disabled = busy || (!task.taskPath && button.dataset.role !== "noop")));
laneMenuEl.querySelectorAll("button").forEach((button) => {
button.disabled = busy;
});
};
const runBusyAction = async (action) => {
setBusy(true);
errorText = "";
setStatus("Saving", "neutral");
try {
await action();
setStatus("", "neutral");
} catch (error) {
console.error("Task card action failed", error);
errorText = error instanceof Error ? error.message : String(error);
setStatus("Unavailable", "error");
} finally {
setBusy(false);
render();
}
};
const callTaskBoard = async (argumentsValue) => {
const result = await host.callTool("task_board", argumentsValue);
const payload = parseToolPayload(result);
if (payload && typeof payload.error === "string" && payload.error.trim()) {
throw new Error(payload.error);
}
return payload;
};
const moveTask = async (lane) =>
runBusyAction(async () => {
const payload = await callTaskBoard({ action: "move", task: task.taskPath, lane });
const nextTask = normalizeTaskFromPayload(payload?.task, {
...task,
lane,
taskPath: typeof payload?.task_path === "string" ? payload.task_path.trim() : task.taskPath,
updated: new Date().toISOString(),
});
task = nextTask;
refreshFeed();
});
const editField = async (field, rawValue) => {
const nextValue = rawValue.trim();
const currentValue = field === "title" ? task.title : task.body;
if (field === "title" && !nextValue) return false;
if (nextValue === currentValue) {
editingField = null;
render();
return true;
}
await runBusyAction(async () => {
const payload = await callTaskBoard({
action: "edit",
task: task.taskPath,
...(field === "title" ? { title: nextValue } : { description: nextValue }),
});
task = normalizeTaskFromPayload(payload?.task, {
...task,
...(field === "title" ? { title: nextValue } : { body: nextValue }),
});
editingField = null;
refreshFeed();
});
return true;
};
const addTag = async () => {
const raw = window.prompt("Add tag to task", "");
const tag = raw == null ? "" : normalizeTag(raw);
if (!tag) return;
await runBusyAction(async () => {
const payload = await callTaskBoard({
action: "add_tag",
task: task.taskPath,
tags: [tag],
});
task = normalizeTaskFromPayload(payload?.task, {
...task,
tags: Array.from(new Set([...task.tags, tag])),
});
refreshFeed();
});
};
const removeTag = async (tag) =>
runBusyAction(async () => {
const payload = await callTaskBoard({
action: "remove_tag",
task: task.taskPath,
tags: [tag],
});
task = normalizeTaskFromPayload(payload?.task, {
...task,
tags: task.tags.filter((value) => value !== tag),
});
refreshFeed();
});
const beginTitleEdit = () => {
if (!task.taskPath || busy || editingField) return;
closeLaneMenu();
editingField = "title";
render();
};
const beginBodyEdit = () => {
if (!task.taskPath || busy || editingField) return;
closeLaneMenu();
editingField = "body";
render();
};
const renderInlineEditor = (targetEl, field, value, placeholder) => {
targetEl.innerHTML = "";
const editor = document.createElement("textarea");
editor.className = `${field === "title" ? "task-card-ui__title" : "task-card-ui__body"} task-card-ui__editor`;
editor.rows = field === "title" ? 1 : Math.max(1, value.split("\n").length);
editor.value = value;
if (placeholder) editor.placeholder = placeholder;
editor.disabled = busy;
targetEl.appendChild(editor);
autosizeEditor(editor);
const cancel = () => {
editingField = null;
render();
};
editor.addEventListener("input", () => {
autosizeEditor(editor);
});
editor.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
cancel();
return;
}
if (field === "title" && event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
editor.blur();
return;
}
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
editor.blur();
}
});
editor.addEventListener("blur", () => {
if (editingField !== field) return;
void editField(field, editor.value);
});
requestAnimationFrame(() => {
editor.focus();
const end = editor.value.length;
editor.setSelectionRange(end, end);
});
};
const renderTags = () => {
tagsEl.innerHTML = "";
task.tags.forEach((tag) => {
const button = document.createElement("button");
button.className = "task-card-ui__tag";
button.type = "button";
button.textContent = tag;
button.title = `Hold to remove ${tag}`;
button.disabled = busy;
button.addEventListener("pointerdown", (event) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
clearHoldTimer();
button.classList.add("is-holding");
holdTimer = window.setTimeout(() => {
holdTimer = null;
button.classList.remove("is-holding");
if (window.confirm(`Remove ${tag} from this task?`)) {
void removeTag(tag);
}
}, 650);
});
["pointerup", "pointerleave", "pointercancel"].forEach((eventName) => {
button.addEventListener(eventName, () => {
clearHoldTimer();
button.classList.remove("is-holding");
});
});
button.addEventListener("contextmenu", (event) => {
event.preventDefault();
});
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
});
tagsEl.appendChild(button);
});
const addButton = document.createElement("button");
addButton.className = "task-card-ui__tag task-card-ui__tag--action";
addButton.type = "button";
addButton.textContent = "+";
addButton.title = "Add tag";
addButton.disabled = busy || !task.taskPath;
addButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
void addTag();
});
tagsEl.appendChild(addButton);
};
const renderLaneMenu = () => {
laneMenuEl.innerHTML = "";
if (!task.taskPath) {
closeLaneMenu();
return;
}
taskMoveOptions(task.lane).forEach((option) => {
const button = document.createElement("button");
button.className = "task-card-ui__lane-menu-item";
button.type = "button";
button.textContent = option.label;
button.disabled = busy;
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
void moveTask(option.lane);
});
laneMenuEl.appendChild(button);
});
laneMenuEl.style.display = laneMenuOpen ? "flex" : "none";
};
const applyTheme = () => {
const theme = TASK_LANE_THEMES[task.lane] || TASK_LANE_THEMES.backlog;
cardEl.style.setProperty("--task-accent", theme.accent);
cardEl.style.setProperty("--task-accent-soft", theme.accentSoft);
cardEl.style.setProperty("--task-muted", theme.muted);
cardEl.style.setProperty("--task-button-ink", theme.buttonInk);
};
const render = () => {
applyTheme();
laneToggleEl.textContent = "";
const laneLabelEl = document.createElement("span");
laneLabelEl.className = "task-card-ui__lane";
laneLabelEl.textContent = TASK_LANE_LABELS[task.lane] || "Task";
const caretEl = document.createElement("span");
caretEl.className = `task-card-ui__lane-caret${laneMenuOpen ? " open" : ""}`;
caretEl.textContent = "▾";
laneToggleEl.append(laneLabelEl, caretEl);
laneToggleEl.disabled = busy || !task.taskPath;
laneToggleEl.setAttribute("aria-expanded", laneMenuOpen ? "true" : "false");
setStatus(statusLabel, statusKind);
if (editingField === "title") {
renderInlineEditor(titleEl, "title", task.title, "");
} else {
titleEl.innerHTML = "";
const button = document.createElement("button");
button.className = "task-card-ui__title task-card-ui__text-button";
button.type = "button";
button.disabled = busy || !task.taskPath;
button.textContent = task.title || "(Untitled task)";
button.addEventListener("click", beginTitleEdit);
titleEl.appendChild(button);
}
const bodySummary = summarizeTaskBody(task);
if (editingField === "body") {
renderInlineEditor(bodyEl, "body", task.body, "Add description");
} else {
bodyEl.innerHTML = "";
const button = document.createElement("button");
button.className = `task-card-ui__body task-card-ui__text-button task-card-ui__body-markdown${
bodySummary ? "" : " is-placeholder"
}`;
button.type = "button";
button.disabled = busy || !task.taskPath;
const inner = document.createElement("span");
inner.className = "task-card-ui__body-markdown-inner";
inner.innerHTML = bodySummary ? renderTaskBodyMarkdown(host, bodySummary) : "Add description";
button.appendChild(inner);
button.addEventListener("click", beginBodyEdit);
bodyEl.appendChild(button);
}
renderTags();
renderLaneMenu();
const dueText = formatTaskDue(task);
dueEl.textContent = dueText;
metaEl.style.display = dueText ? "flex" : "none";
publishLiveContent();
setBusy(busy);
};
const handleLaneToggle = (event) => {
event.preventDefault();
event.stopPropagation();
if (laneMenuOpen) closeLaneMenu();
else openLaneMenu();
render();
};
const handleDocumentPointerDown = (event) => {
if (!laneMenuOpen) return;
if (!(event.target instanceof Node)) return;
if (laneWrapEl.contains(event.target)) return;
closeLaneMenu();
render();
};
const handleEscape = (event) => {
if (event.key !== "Escape" || !laneMenuOpen) return;
closeLaneMenu();
render();
};
laneToggleEl.addEventListener("click", handleLaneToggle);
document.addEventListener("pointerdown", handleDocumentPointerDown);
document.addEventListener("keydown", handleEscape);
render();
return {
update({ item: nextItem, state: nextState }) {
task = normalizeTask(nextState, nextItem.title);
errorText = "";
statusLabel = "";
statusKind = "neutral";
laneMenuOpen = false;
editingField = null;
render();
},
destroy() {
clearHoldTimer();
document.removeEventListener("pointerdown", handleDocumentPointerDown);
document.removeEventListener("keydown", handleEscape);
laneToggleEl.removeEventListener("click", handleLaneToggle);
host.setRefreshHandler(null);
host.setLiveContent(null);
host.clearSelection();
},
};
}