709 lines
22 KiB
JavaScript
709 lines
22 KiB
JavaScript
|
|
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();
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|