dmslanes/lanes
Lanes - team task boards with roles, history and an MCP endpoint for Claude and ChatGPT
A remix template · 0 remixes · Remix dmslanes/lanes
app/web/static/lanes.js
// Lanes web page: plain JS over the /api routes (the same use cases the MCP tools call).
const $ = (s, el = document) => el.querySelector(s);
const main = $("#main");
const state = { me: null, ws: null, view: "boards", board: null, members: [] };
const devUser = new URLSearchParams(location.search).get("as"); // local dev only: ?as=mike@team.io
async function api(path, opts = {}) {
const headers = { "content-type": "application/json", ...(devUser ? { "x-dev-user": devUser } : {}) };
const res = await fetch(path, { ...opts, headers, body: opts.body ? JSON.stringify(opts.body) : undefined });
if (res.status === 401) { location.href = "/.dodil/auth/login?next=" + encodeURIComponent(location.pathname); throw new Error("sign in"); }
if (res.status === 204) return null;
const data = await res.json().catch(() => ({}));
if (!res.ok) { toast(data.detail || "Something went wrong"); throw new Error(data.detail); }
return data;
}
function toast(msg) {
const t = Object.assign(document.createElement("div"), { className: "toast", textContent: msg });
document.body.append(t); setTimeout(() => t.remove(), 3200);
}
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
const today = () => new Date().toISOString().slice(0, 10);
const initials = (e) => (e || "").split("@")[0].split(/[._-]/).map((p) => p[0]).join("").slice(0, 2).toUpperCase();
// mirrors DONE_LIST_NAMES in app/domain/model.py: a card in a done list is never late
const DONE_LISTS = new Set(["done", "finished", "complete", "completed", "shipped"]);
function cardHtml(c, withContext = false) {
const late = c.due_date && c.due_date < today() && !DONE_LISTS.has((c.list || "").toLowerCase());
return `<article class="card" draggable="${state.board?.your_role !== "viewer"}" data-n="${c.number}">
<div class="t">${esc(c.title)}</div>
<div class="meta"><span class="num">#${c.number}</span>
${withContext ? `<span>${esc(c.board)} · ${esc(c.list)}</span>` : ""}
${c.due_date ? `<span class="due ${late ? "late" : ""}">${c.due_date}</span>` : ""}
${c.labels.map((l) => `<span class="lbl">${esc(l)}</span>`).join("")}
${(c.blocked_by || []).length ? `<span class="blk" title="Blocked by">⛓ ${c.blocked_by.map((n) => "#" + n).join(" ")}</span>` : ""}
${c.assignee ? `<span class="av" title="${esc(c.assignee)}">${initials(c.assignee)}</span>` : ""}
</div></article>`;
}
async function boot() {
state.me = await api("/api/me");
$("#who").textContent = state.me.email;
const sel = $("#ws");
sel.innerHTML = state.me.workspaces.map((w) => `<option value="${w.id}">${esc(w.name)}</option>`).join("") +
`<option value="__new">+ New workspace</option>`;
state.ws = state.me.workspaces.find((w) => w.id === localStorage.lanesWs) || state.me.workspaces[0] || null;
if (state.ws) sel.value = state.ws.id;
sel.onchange = () => {
if (sel.value === "__new") { state.ws = null; return renderEmpty(); }
state.ws = state.me.workspaces.find((w) => w.id === sel.value); localStorage.lanesWs = state.ws.id; show("boards");
};
document.querySelectorAll(".views button").forEach((b) => (b.onclick = () => show(b.dataset.view)));
const m = location.hash.match(/^#board\/(.+)$/);
if (!state.ws) return renderEmpty();
m ? openBoard(m[1]) : show("boards");
}
function show(view) {
state.view = view;
document.querySelectorAll(".views button").forEach((b) => b.classList.toggle("on", b.dataset.view === view));
if (!state.ws && view !== "plate" && view !== "overdue") return renderEmpty();
({ boards: renderBoards, plate: renderPlate, overdue: renderOverdue, people: renderPeople })[view]();
}
function renderEmpty() {
main.replaceChildren($("#tplEmpty").content.cloneNode(true));
$("#newWs").onsubmit = async (e) => {
e.preventDefault();
const ws = await api("/api/workspaces", { method: "POST", body: { name: e.target.name.value } });
localStorage.lanesWs = ws.id; boot();
};
}
async function renderBoards() {
location.hash = "";
const boards = (await api("/api/boards")).filter((b) => b.workspace_id === state.ws.id);
const canEdit = state.ws.role !== "viewer";
main.innerHTML = `<div class="bar"><h1 class="grow">${esc(state.ws.name)}</h1><span class="role">${state.ws.role}</span></div>
${canEdit ? `<form id="newBoard" class="inline"><input name="name" placeholder="New board, e.g. Ops" required maxlength="200"><button class="primary">Add board</button></form>` : ""}
<div class="boards">${boards.map((b) => `<button class="tile" data-id="${b.id}">${esc(b.name)}</button>`).join("") ||
`<p class="muted">No boards yet.</p>`}</div>`;
main.querySelectorAll(".tile").forEach((t) => (t.onclick = () => openBoard(t.dataset.id)));
if (canEdit) $("#newBoard").onsubmit = async (e) => {
e.preventDefault();
const b = await api("/api/boards", { method: "POST", body: { workspace_id: state.ws.id, name: e.target.name.value } });
openBoard(b.id);
};
}
async function openBoard(id) {
state.board = await api(`/api/boards/${id}`);
location.hash = `board/${id}`;
const ws = state.me.workspaces.find((w) => w.id === state.board.workspace_id);
if (ws && ws.id !== state.ws?.id) { state.ws = ws; $("#ws").value = ws.id; }
state.members = await api(`/api/workspaces/${state.board.workspace_id}/members`);
renderBoard();
}
function renderBoard() {
const b = state.board, ro = b.your_role === "viewer";
main.innerHTML = `<div class="bar"><button class="link" id="back">← Boards</button><h2 class="grow">${esc(b.name)}</h2><button class="link" id="sum">✦ Daily summary</button><span class="role">${b.your_role}</span></div>
<div id="sumBox"></div>
<div class="board ${ro ? "ro" : ""}">${b.lists.map((l) => `
<section class="lane" data-list="${l.id}"><h3>${esc(l.name)} <span>${l.cards.length}</span></h3>
<div class="cards">${l.cards.map((c) => cardHtml({ ...c, list: l.name })).join("")}</div>
<form class="add"><input name="title" placeholder="+ Add a card" maxlength="200" aria-label="Add a card to ${esc(l.name)}"></form>
</section>`).join("")}
<section class="lane addlane"><form class="add" id="newList"><input name="name" placeholder="+ Add a list" maxlength="200"></form></section>
</div>`;
$("#back").onclick = () => show("boards");
$("#sum").onclick = async () => {
$("#sumBox").innerHTML = `<p class="summary muted">Writing the summary…</p>`;
const r = await api(`/api/boards/${b.id}/summary`).catch(() => null);
$("#sumBox").innerHTML = r ? `<p class="summary">${esc(r.summary)}</p>` : "";
};
main.querySelectorAll(".card").forEach(wireCard);
main.querySelectorAll(".lane[data-list]").forEach(wireLane);
main.querySelectorAll(".lane[data-list] form.add").forEach((f) => (f.onsubmit = async (e) => {
e.preventDefault();
const list = f.closest(".lane").dataset.list;
await api(`/api/boards/${b.id}/cards`, { method: "POST", body: { list, title: f.title.value } });
openBoard(b.id);
}));
$("#newList").onsubmit = async (e) => {
e.preventDefault();
await api(`/api/boards/${b.id}/lists`, { method: "POST", body: { name: e.target.name.value } });
openBoard(b.id);
};
}
function wireCard(el) {
el.onclick = () => openCard(el.dataset.n);
el.ondragstart = (e) => { e.dataTransfer.setData("text/plain", el.dataset.n); el.classList.add("dragging"); };
el.ondragend = () => el.classList.remove("dragging");
}
function wireLane(lane) {
const box = $(".cards", lane);
lane.ondragover = (e) => { e.preventDefault(); lane.classList.add("over"); };
lane.ondragleave = () => lane.classList.remove("over");
lane.ondrop = async (e) => {
e.preventDefault(); lane.classList.remove("over");
const n = e.dataTransfer.getData("text/plain");
const others = [...box.querySelectorAll(".card")].filter((c) => c.dataset.n !== n);
let position = others.findIndex((c) => e.clientY < c.getBoundingClientRect().top + c.offsetHeight / 2);
if (position < 0) position = others.length;
await api(`/api/cards/${n}/move`, { method: "POST", body: { to_list: lane.dataset.list, position } });
openBoard(state.board.id);
};
}
async function openCard(n) {
const c = await api(`/api/cards/${n}`);
if (!state.board || state.board.id !== c.board_id) {
state.board = await api(`/api/boards/${c.board_id}`);
state.members = await api(`/api/workspaces/${state.board.workspace_id}/members`);
}
const ro = state.board.your_role === "viewer", f = $("#cardForm"), dlg = $("#cardDlg");
$("#cardNo").textContent = `#${c.number} · ${c.board} · ${c.list}`;
f.title.value = c.title; f.description.value = c.description; f.due_date.value = c.due_date || "";
f.labels.value = c.labels.join(", ");
f.assignee.innerHTML = `<option value="">Nobody</option>` + state.members.map((m) =>
`<option ${m.email === c.assignee ? "selected" : ""}>${esc(m.email)}</option>`).join("");
[...f.elements].forEach((el) => { if (el.name) el.disabled = ro; });
$("#delCard").hidden = ro; $(".primary", f).hidden = ro;
$("#blockIn").hidden = $("#blockAdd").hidden = $("#aiChecklist").hidden = ro;
$("#similar").innerHTML = "";
$("#blockers").innerHTML = (c.blocked_by || []).map((b) => `<span class="chip">#${b}${ro ? "" :
`<button type="button" data-unblock="${b}" aria-label="Remove #${b}">×</button>`}</span>`).join("") || `<span class="muted">nothing</span>`;
const reopen = () => { dlg.close(); openCard(n); };
$("#blockers").querySelectorAll("[data-unblock]").forEach((x) => (x.onclick = async () => {
await api(`/api/cards/${n}/blocked-by/${x.dataset.unblock}`, { method: "DELETE" }); reopen();
}));
$("#blockAdd").onclick = async () => {
const v = $("#blockIn").value.replace("#", "").trim(); if (!v) return;
await api(`/api/cards/${n}/blocked-by`, { method: "POST", body: { blocked_by: v } }); $("#blockIn").value = ""; reopen();
};
$("#aiChecklist").onclick = async (e) => {
e.target.textContent = "✦ Thinking…";
await api(`/api/cards/${n}/checklist`, { method: "POST" }).finally(() => (e.target.textContent = "✦ Turn into a checklist"));
reopen();
};
$("#aiSimilar").onclick = async (e) => {
e.target.textContent = "✦ Looking…";
const sim = await api(`/api/cards/${n}/similar`).finally(() => (e.target.textContent = "✦ Find similar cards"));
$("#similar").innerHTML = sim.map((s) => `<li><a href="#" data-n="${s.number}">#${s.number} ${esc(s.title)}</a>
<span class="num">${esc(s.board)} · ${esc(s.list)} · ${Math.round(s.similarity * 100)}%</span></li>`).join("") || "<li>No other cards yet.</li>";
$("#similar").querySelectorAll("a").forEach((a) => (a.onclick = (ev) => { ev.preventDefault(); dlg.close(); openCard(a.dataset.n); }));
};
const hist = await api(`/api/cards/${n}/history`);
$("#history").innerHTML = hist.map((h) => `<li><b>${esc(h.actor)}</b> ${h.action} ${Object.entries(h.changes)
.map(([k, [a, b]]) => `${k}: ${esc(a ?? "–")} → ${esc(b ?? "–")}`).join("; ")} <span class="num">${h.at.slice(0, 16).replace("T", " ")}</span></li>`).join("");
$("#delCard").onclick = async () => { if (confirm("Delete this card?")) { await api(`/api/cards/${n}`, { method: "DELETE" }); dlg.close(); openBoard(c.board_id); } };
dlg.onclose = async () => {
if (dlg.returnValue !== "save" || ro) return;
const labels = f.labels.value.split(",").map((s) => s.trim()).filter(Boolean);
await api(`/api/cards/${n}`, { method: "PATCH", body: { title: f.title.value, description: f.description.value,
labels, version: c.version, ...(f.due_date.value ? { due_date: f.due_date.value } : { clear_due_date: true }) } });
if ((f.assignee.value || null) !== c.assignee) await api(`/api/cards/${n}/assign`, { method: "POST", body: { assignee: f.assignee.value || null } });
state.view === "boards" ? openBoard(c.board_id) : show(state.view);
};
dlg.returnValue = ""; dlg.showModal();
}
async function renderList(title, path, empty) {
location.hash = "";
const cards = await api(path);
main.innerHTML = `<div class="bar"><h1 class="grow">${title}</h1></div>
<div class="list">${cards.map((c) => cardHtml(c, true)).join("") || `<p class="muted">${empty}</p>`}</div>`;
main.querySelectorAll(".card").forEach((el) => { el.draggable = false; el.onclick = () => openCard(el.dataset.n); });
}
const renderPlate = () => renderList("My plate this week", "/api/my-cards?days=7", "Nothing due this week. Nice.");
const renderOverdue = () => renderList("Overdue", "/api/overdue", "Nothing overdue.");
async function renderPeople() {
location.hash = "";
const members = await api(`/api/workspaces/${state.ws.id}/members`), owner = state.ws.role === "owner";
main.innerHTML = `<div class="bar"><h1 class="grow">People in ${esc(state.ws.name)}</h1></div>
${owner ? `<form id="invite" class="inline" style="margin-bottom:12px"><input name="email" type="email" placeholder="email@company.com" required>
<select name="role" style="width:auto"><option>member</option><option>viewer</option></select><button class="primary">Invite</button></form>` : ""}
<table><tr><th>Email</th><th>Role</th><th></th></tr>${members.map((m) => `<tr><td>${esc(m.email)}</td><td>${m.role}</td>
<td>${owner && m.role !== "owner" ? `<button class="link danger" data-rm="${esc(m.email)}">Remove</button>` : ""}</td></tr>`).join("")}</table>
<p class="muted">Members edit cards and boards. Viewers read only. Only the owner manages people. People sign in with the email you invite.</p>`;
if (owner) $("#invite").onsubmit = async (e) => {
e.preventDefault();
await api(`/api/workspaces/${state.ws.id}/members`, { method: "POST", body: { email: e.target.email.value, role: e.target.role.value } });
renderPeople();
};
main.querySelectorAll("[data-rm]").forEach((b) => (b.onclick = async () => {
await api(`/api/workspaces/${state.ws.id}/members/${encodeURIComponent(b.dataset.rm)}`, { method: "DELETE" }); renderPeople();
}));
}
boot();