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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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();