const states = ["Backlog", "In Progress", "Review", "Done"];
const priorities = ["Low", "Normal", "Medium", "High", "Urgent"];
const assignees = [
{ value: "bao", label: "Bao" },
{ value: "maxi", label: "Maxi" },
{ value: "luna", label: "Luna" },
{ value: "iris", label: "Iris" }
];
const createTaskDefaultAssignee = "bao";
const app = document.querySelector("#app");
const state = {
user: null,
tasks: [],
settings: null,
openClaw: null,
view: "board",
error: ""
};
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: "same-origin",
headers: { "content-type": "application/json", ...(options.headers || {}) },
...options
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
return data;
}
async function init() {
try {
const session = await api("/api/session");
state.user = session.user;
await Promise.all([loadTasks(), loadSettings()]);
renderApp();
} catch {
renderLogin();
}
}
function renderLogin() {
app.innerHTML = `
`;
document.querySelector("#login-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
const result = await api("/api/login", {
method: "POST",
body: JSON.stringify({ email: form.get("email"), password: form.get("password") })
});
state.user = result.user;
await Promise.all([loadTasks(), loadSettings()]);
renderApp();
} catch (error) {
document.querySelector("#login-error").textContent = error.message;
}
});
}
async function loadTasks() {
const result = await api("/api/tasks");
state.tasks = result.tasks;
}
async function loadSettings() {
const result = await api("/api/settings");
state.settings = result.settings;
state.openClaw = result.openClaw;
}
function renderApp() {
app.innerHTML = `
${state.view === "settings" ? settingsView() : boardView()}
${taskDialog()}
`;
document.querySelectorAll("[data-view]").forEach((button) => {
button.addEventListener("click", async () => {
state.view = button.dataset.view;
if (state.view === "settings") await loadSettings();
renderApp();
});
});
document.querySelector("#logout").addEventListener("click", async () => {
await api("/api/logout", { method: "POST" });
state.user = null;
renderLogin();
});
if (state.view === "board") bindBoard();
if (state.view === "settings") bindSettings();
}
function boardView() {
const connected = state.openClaw?.ok;
return `
${states.map((name) => column(name)).join("")}
`;
}
function column(name) {
const tasks = state.tasks.filter((task) => task.state === name);
return `
${tasks.map(taskCard).join("") || `
Keine Aufgaben
`}
`;
}
function taskCard(task) {
return `
${escapeHtml(task.priority)}
${escapeHtml(task.title)}
${task.detail ? `
${escapeHtml(task.detail)}
` : ""}
`;
}
function taskDialog() {
return `
`;
}
function bindBoard() {
const dialog = document.querySelector("#task-dialog");
const form = document.querySelector("#task-form");
const dialogTitle = document.querySelector("#dialog-title");
const dialogMeta = document.querySelector("#dialog-meta");
const saveTaskButton = document.querySelector("#save-task");
let draggedTaskId = null;
const resetDialog = () => {
form.reset();
form.elements.id.value = "";
form.elements.state.value = "Backlog";
form.elements.priority.value = "Medium";
form.elements.assignee.value = createTaskDefaultAssignee;
form.classList.add("task-form-create");
dialogTitle.textContent = "Neue Aufgabe";
dialogMeta.textContent = "Erstelle eine neue Aufgabe fuer das Mission-Control-Board.";
saveTaskButton.textContent = "Aufgabe erstellen";
};
const openTaskDialog = (task) => {
form.classList.remove("task-form-create");
form.elements.id.value = task.id;
form.elements.title.value = task.title;
form.elements.detail.value = task.detail || "";
form.elements.state.value = task.state;
form.elements.priority.value = task.priority;
ensureAssigneeOption(form.elements.assignee, task.assignee || state.settings?.defaultAssignee || "luna");
form.elements.assignee.value = normalizeAssignee(task.assignee || state.settings?.defaultAssignee || "luna");
dialogTitle.textContent = task.title;
dialogMeta.textContent = `Status ${task.state} · Aktualisiert ${formatDate(task.updatedAt)} · Erstellt ${formatDate(task.createdAt)}`;
saveTaskButton.textContent = "Speichern";
dialog.showModal();
};
document.querySelector("#open-create-task").addEventListener("click", () => {
resetDialog();
dialog.showModal();
});
document.querySelector("#cancel-task").addEventListener("click", () => dialog.close());
dialog.addEventListener("click", (event) => {
if (event.target === dialog) dialog.close();
});
document.querySelectorAll("[data-status-task]").forEach((select) => {
select.addEventListener("change", async (event) => {
event.stopPropagation();
await api(`/api/tasks/${select.dataset.statusTask}`, { method: "PATCH", body: JSON.stringify({ state: select.value }) });
await loadTasks();
renderApp();
});
});
document.querySelectorAll("[data-edit]").forEach((button) => {
button.addEventListener("click", (event) => {
event.stopPropagation();
const task = state.tasks.find((x) => x.id === button.dataset.edit);
if (task) openTaskDialog(task);
});
});
document.querySelectorAll("[data-task-id]").forEach((card) => {
card.addEventListener("click", (event) => {
if (event.target.closest("button, input, textarea, select, a")) return;
const task = state.tasks.find((x) => x.id === card.dataset.taskId);
if (task) openTaskDialog(task);
});
card.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
const task = state.tasks.find((x) => x.id === card.dataset.taskId);
if (task) openTaskDialog(task);
});
card.addEventListener("dragstart", (event) => {
draggedTaskId = card.dataset.taskId;
card.classList.add("dragging");
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", draggedTaskId);
});
card.addEventListener("dragend", () => {
draggedTaskId = null;
card.classList.remove("dragging");
document.querySelectorAll(".dropzone.drag-over").forEach((zone) => zone.classList.remove("drag-over"));
});
});
document.querySelectorAll(".dropzone").forEach((zone) => {
zone.addEventListener("dragover", (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
zone.classList.add("drag-over");
});
zone.addEventListener("dragleave", (event) => {
if (!zone.contains(event.relatedTarget)) zone.classList.remove("drag-over");
});
zone.addEventListener("drop", async (event) => {
event.preventDefault();
zone.classList.remove("drag-over");
const taskId = event.dataTransfer.getData("text/plain") || draggedTaskId;
const nextState = zone.dataset.state;
const task = state.tasks.find((x) => x.id === taskId);
if (!task || task.state === nextState) return;
await api(`/api/tasks/${taskId}`, { method: "PATCH", body: JSON.stringify({ state: nextState }) });
await loadTasks();
renderApp();
});
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(form));
const path = data.id ? `/api/tasks/${data.id}` : "/api/tasks";
await api(path, { method: data.id ? "PATCH" : "POST", body: JSON.stringify(data) });
dialog.close();
await loadTasks();
renderApp();
});
}
function settingsView() {
const openClaw = state.openClaw || {};
return `
OpenClaw
${openClaw.ok ? "Verbunden" : "Nicht verbunden"}
Gateway: ${escapeHtml(openClaw.baseUrl || state.settings?.openClawBaseUrl || "")}
Health: ${escapeHtml(String(openClaw.healthStatus || openClaw.error || "unbekannt"))} · Tool: ${escapeHtml(String(openClaw.toolResult || "nicht geprueft"))} · ${escapeHtml(String(openClaw.latencyMs || 0))} ms
`;
}
function bindSettings() {
document.querySelector("#refresh-settings").addEventListener("click", async () => {
await loadSettings();
renderApp();
});
document.querySelector("#settings-form").addEventListener("submit", async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget));
await api("/api/settings", { method: "PATCH", body: JSON.stringify(data) });
await loadSettings();
renderApp();
});
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function formatDate(value) {
if (!value) return "unbekannt";
return new Date(value).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
}
function normalizeAssignee(value) {
return String(value || "").trim().toLowerCase();
}
function assigneeLabel(value) {
const normalized = normalizeAssignee(value);
const known = assignees.find((x) => x.value === normalized);
if (known) return known.label;
return normalized ? normalized.charAt(0).toUpperCase() + normalized.slice(1) : "Luna";
}
function assigneeOptions(selected) {
const normalized = normalizeAssignee(selected);
const options = [...assignees];
if (normalized && !options.some((x) => x.value === normalized)) {
options.push({ value: normalized, label: assigneeLabel(normalized) });
}
return options.map((x) => ``).join("");
}
function ensureAssigneeOption(select, value) {
const normalized = normalizeAssignee(value);
if (!normalized || Array.from(select.options).some((option) => option.value === normalized)) return;
select.add(new Option(assigneeLabel(normalized), normalized));
}
init();