ghpsolutions/quadro

Quadro: team boards with roles, history and an MCP endpoint for Claude and ChatGPT

A remix template · 0 remixes · Remix ghpsolutions/quadro

app/boards/doors/static/app.js

"use strict";
// Quadro web page. Plain JavaScript, no build step: it talks to the same /api use cases the
// MCP tools call.

const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const LABEL_COLORS = ["slate", "red", "amber", "green", "teal", "blue", "violet", "pink"];
const AVATAR_COLORS = ["#2f5d50", "#7b5ea7", "#b4412f", "#2d4f86", "#8d5a10", "#3e6b2f", "#8a2f58", "#4a4843"];

const state = { me: null, boards: [], board: null, view: null, filter: "all", query: "", card: null, drag: null };

const hash = (s) => [...String(s)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7);
const labelClass = (name) => "l-" + LABEL_COLORS[hash(name.toLowerCase()) % LABEL_COLORS.length];
const initials = (email) => { const n = String(email).split("@")[0].split(/[._-]+/).filter(Boolean); return ((n[0] || "?")[0] + (n[1] ? n[1][0] : (n[0] || "").slice(1, 2))).toUpperCase(); };
const avatar = (email, title = email) => email ? `<span class="avatar" title="${esc(title)}" style="background:${AVATAR_COLORS[hash(email) % AVATAR_COLORS.length]}">${esc(initials(email))}</span>` : "";
const canEdit = () => state.board && state.board.role !== "viewer";

const ICON = {
  clock: '<svg class="i" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
  chat: '<svg class="i" viewBox="0 0 24 24"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1.1-4.6A8 8 0 1 1 21 12z"/></svg>',
  check: '<svg class="i" viewBox="0 0 24 24"><path d="M4 12l5 5L20 6"/></svg>',
  lock: '<svg class="i" viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>',
  text: '<svg class="i" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>',
};

// ---------------------------------------------------------------- api

async function api(method, path, body) {
  const opts = { method, headers: { "content-type": "application/json" } };
  if (body !== undefined) opts.body = JSON.stringify(body);
  const res = await fetch(path, opts);
  if (res.status === 401) { location.href = "/.dodil/auth/login?return_to=" + encodeURIComponent(location.pathname + location.hash); throw new Error("sign in"); }
  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    const msg = typeof data.detail === "string" ? data.detail : "Something went wrong";
    const err = new Error(msg); err.status = res.status; throw err;
  }
  return data;
}

function toast(msg, bad = false) {
  const t = document.createElement("div");
  t.className = "toast" + (bad ? " bad" : "");
  t.textContent = msg;
  $("#toasts").append(t);
  setTimeout(() => t.remove(), bad ? 5200 : 2600);
}

async function attempt(fn, okMsg) {
  try { const r = await fn(); if (okMsg) toast(okMsg); return r; }
  catch (e) { if (e.message !== "sign in") toast(e.message, true); if (e.status === 409) await refresh(); return null; }
}

// ---------------------------------------------------------------- dates

const today = () => new Date().toISOString().slice(0, 10);
function dueText(d) {
  if (!d) return "";
  const days = Math.round((Date.parse(d) - Date.parse(today())) / 864e5);
  if (days === 0) return "Today";
  if (days === 1) return "Tomorrow";
  if (days === -1) return "Yesterday";
  const dt = new Date(d + "T00:00:00");
  return dt.toLocaleDateString(undefined, { month: "short", day: "numeric", ...(dt.getFullYear() !== new Date().getFullYear() ? { year: "numeric" } : {}) });
}
function dueClass(c) { if (!c.due_date || c.done) return ""; if (c.overdue) return "late"; const d = (Date.parse(c.due_date) - Date.parse(today())) / 864e5; return d <= 2 ? "soon" : ""; }
function ago(iso) {
  const s = (Date.now() - Date.parse(iso)) / 1000;
  if (s < 60) return "just now";
  if (s < 3600) return Math.floor(s / 60) + " min ago";
  if (s < 86400) return Math.floor(s / 3600) + " h ago";
  return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
}

// ---------------------------------------------------------------- routing

function go(hashValue) { if (location.hash !== hashValue) location.hash = hashValue; else route(); }
async function route() {
  const h = location.hash.slice(1);
  closeDrawer(true);
  if (h.startsWith("board/")) await openBoard(decodeURIComponent(h.slice(6)));
  else if (h.startsWith("card/")) { const id = decodeURIComponent(h.slice(5)); await openBoard(id.split("-")[0]); await openCard(id); }
  else if (h === "overdue") await showOverdue();
  else if (h.startsWith("search/")) await showSearch(decodeURIComponent(h.slice(7)));
  else if (h === "plate" || !state.boards.length) await showPlate();
  else go("#board/" + state.boards[0].id);
  markNav();
}
function markNav() {
  const h = location.hash.slice(1);
  $$(".nav").forEach((b) => b.classList.toggle("on", b.dataset.view === h || ("board/" + b.dataset.board) === h));
}

// ---------------------------------------------------------------- sidebar

async function loadBoards() {
  state.boards = await api("GET", "/api/boards");
  $("#board-nav").innerHTML = state.boards.map((b) =>
    `<button class="nav" data-board="${esc(b.id)}"><span>${esc(b.title)}</span><span class="key">${esc(b.key)}</span></button>`).join("")
    || `<p class="empty" style="padding:8px 10px;text-align:left">No boards yet.</p>`;
  $$("#board-nav .nav").forEach((b) => b.onclick = () => go("#board/" + b.dataset.board));
  markNav();
  api("GET", "/api/overdue").then((list) => {
    const el = $("#overdue-count"); el.hidden = !list.length; el.textContent = list.length;
  }).catch(() => {});
}

// ---------------------------------------------------------------- board view

async function openBoard(id) {
  state.view = "board";
  const board = await attempt(() => api("GET", "/api/boards/" + encodeURIComponent(id)));
  if (!board) { await showPlate(); return; }
  state.board = board;
  renderBoardTop();
  renderBoard();
}

async function refresh() {
  if (state.view === "board" && state.board) {
    state.board = await api("GET", "/api/boards/" + state.board.id);
    renderBoard();
    renderBoardTop();
  }
  if (state.card) await openCard(state.card.id, true);
}

function renderBoardTop() {
  const b = state.board;
  $("#top").innerHTML = `
    <h1>${esc(b.title)}</h1><span class="key">${esc(b.key)}</span>
    <div class="seg" role="group" aria-label="Filter">
      ${[["all", "All"], ["mine", "Mine"], ["overdue", "Overdue"], ["blocked", "Blocked"]].map(([k, v]) => `<button data-f="${k}" class="${state.filter === k ? "on" : ""}">${v}</button>`).join("")}
    </div>
    <span class="grow"></span>
    <input class="search" id="board-search" placeholder="Filter cards…" value="${esc(state.query)}" aria-label="Filter cards">
    <button class="people" id="people-btn" title="Members" style="border:0;background:none;padding:0">${b.members.slice(0, 5).map((m) => avatar(m.email, `${m.email} · ${m.role}`)).join("")}</button>
    <button class="btn" id="summary-btn">Daily summary</button>
    <button class="btn ghost" id="activity-btn">History</button>`;
  $$(".seg button", $("#top")).forEach((btn) => btn.onclick = () => { state.filter = btn.dataset.f; renderBoardTop(); renderBoard(); });
  $("#board-search").oninput = (e) => { state.query = e.target.value; renderBoard(); };
  $("#board-search").onkeydown = (e) => { if (e.key === "Enter" && e.target.value.trim()) go("#search/" + encodeURIComponent(e.target.value.trim())); };
  $("#people-btn").onclick = membersDialog;
  $("#summary-btn").onclick = summaryDialog;
  $("#activity-btn").onclick = activityDrawer;
}

function matches(c) {
  if (state.filter === "mine" && c.assignee !== state.me) return false;
  if (state.filter === "overdue" && !c.overdue) return false;
  if (state.filter === "blocked" && !c.blocked) return false;
  const q = state.query.trim().toLowerCase();
  if (q && !(c.title + " " + c.description + " " + c.labels.join(" ") + " " + c.ref + " " + (c.assignee || "")).toLowerCase().includes(q)) return false;
  return true;
}

function cardHtml(c) {
  const meta = [`<span class="ref">${esc(c.ref)}</span>`];
  if (c.due_date) meta.push(`<span class="flag due ${dueClass(c)}" title="Due ${esc(c.due_date)}">${ICON.clock}${esc(dueText(c.due_date))}</span>`);
  if (c.checklist.total) meta.push(`<span class="flag" title="Checklist">${ICON.check}${c.checklist.done}/${c.checklist.total}</span>`);
  if (c.comments) meta.push(`<span class="flag" title="Comments">${ICON.chat}${c.comments}</span>`);
  if (c.blocked) meta.push(`<span class="flag blocked" title="Waiting on another card">${ICON.lock}Blocked</span>`);
  else if (c.description && !c.checklist.total) meta.push(`<span class="flag" title="Has a description">${ICON.text}</span>`);
  if (c.assignee) meta.push(avatar(c.assignee));
  return `<article class="card${c.overdue ? " overdue" : ""}${c.done ? " is-done" : ""}" data-id="${esc(c.id)}" draggable="${canEdit()}" tabindex="0">
    ${c.labels.length ? `<div class="labels">${c.labels.map((l) => `<span class="label ${labelClass(l)}">${esc(l)}</span>`).join("")}</div>` : ""}
    <div class="title">${esc(c.title)}</div>
    <div class="meta">${meta.join("")}</div></article>`;
}

function renderBoard() {
  const b = state.board;
  const view = $("#view");
  const notice = b.role === "viewer" ? `<div class="notice">You have view access to this board. Ask ${esc(b.members[0].email)} for member access to make changes.</div>` : "";
  view.innerHTML = notice + `<div class="board">${b.lists.map((l) => {
    const cards = l.cards.filter(matches);
    return `<section class="col" data-list="${esc(l.id)}">
      <header class="col-head"><h2>${esc(l.name)}</h2><span class="count">${cards.length}${cards.length !== l.cards.length ? " of " + l.cards.length : ""}</span>
        ${l.done ? `<span class="done-mark" title="Cards here count as done">Done</span>` : ""}
        ${canEdit() ? `<button class="icon-btn" data-menu="${esc(l.id)}" aria-label="List options">⋯</button>` : ""}</header>
      <div class="cards" data-list="${esc(l.id)}">${cards.map(cardHtml).join("")}</div>
      ${canEdit() ? `<button class="add-card" data-add="${esc(l.id)}">+ Add a card</button>` : ""}
    </section>`;
  }).join("")}
  ${canEdit() ? `<div class="new-col"><button class="btn ghost" id="add-list">+ Add list</button></div>` : ""}</div>`;

  $$(".card", view).forEach((el) => {
    el.onclick = () => openCard(el.dataset.id);
    el.onkeydown = (e) => { if (e.key === "Enter") openCard(el.dataset.id); };
  });
  $$("[data-add]", view).forEach((btn) => btn.onclick = () => addCardForm(btn));
  $$("[data-menu]", view).forEach((btn) => btn.onclick = () => listDialog(btn.dataset.menu));
  const addList = $("#add-list"); if (addList) addList.onclick = newListDialog;
  if (canEdit()) wireDrag(view);
}

function addCardForm(btn) {
  const listId = btn.dataset.add;
  const form = document.createElement("form");
  form.className = "add-form";
  form.innerHTML = `<textarea class="textarea" style="min-height:60px" placeholder="Card title" required></textarea>
    <div class="row"><button class="btn primary">Add card</button><button type="button" class="btn ghost">Cancel</button></div>`;
  btn.replaceWith(form);
  const ta = $("textarea", form); ta.focus();
  ta.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); } if (e.key === "Escape") renderBoard(); };
  $(".btn.ghost", form).onclick = () => renderBoard();
  form.onsubmit = async (e) => {
    e.preventDefault();
    const title = ta.value.trim(); if (!title) return;
    const ok = await attempt(() => api("POST", `/api/boards/${state.board.id}/cards`, { title, list: listId }));
    if (ok) { await refresh(); const again = $(`[data-add="${listId}"]`); if (again) addCardForm(again); }
  };
}

// drag and drop between and within lists
function wireDrag(root) {
  const placeholder = document.createElement("div"); placeholder.className = "placeholder";
  $$(".card", root).forEach((el) => {
    el.addEventListener("dragstart", (e) => { state.drag = el.dataset.id; el.classList.add("dragging"); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", el.dataset.id); });
    el.addEventListener("dragend", () => { el.classList.remove("dragging"); placeholder.remove(); $$(".cards.over").forEach((c) => c.classList.remove("over")); state.drag = null; });
  });
  $$(".cards", root).forEach((zone) => {
    zone.addEventListener("dragover", (e) => {
      if (!state.drag) return;
      e.preventDefault();
      zone.classList.add("over");
      const after = [...zone.querySelectorAll(".card:not(.dragging)")].find((c) => e.clientY < c.getBoundingClientRect().top + c.offsetHeight / 2);
      if (after) zone.insertBefore(placeholder, after); else zone.append(placeholder);
    });
    zone.addEventListener("dragleave", (e) => { if (!zone.contains(e.relatedTarget)) zone.classList.remove("over"); });
    zone.addEventListener("drop", async (e) => {
      e.preventDefault();
      const id = state.drag; if (!id) return;
      const index = [...zone.children].filter((n) => n === placeholder || (n.classList.contains("card") && !n.classList.contains("dragging"))).indexOf(placeholder);
      const dragged = $(`.card[data-id="${CSS.escape(id)}"]`);
      placeholder.replaceWith(dragged); dragged.classList.remove("dragging");
      zone.classList.remove("over");
      const card = state.board.lists.flatMap((l) => l.cards).find((c) => c.id === id);
      const filtered = state.filter !== "all" || state.query.trim();
      // With a filter on, hidden cards make a visual index ambiguous, so drop at the end.
      await attempt(() => api("POST", `/api/cards/${id}/move`, { list: zone.dataset.list, index: filtered ? null : index, version: card && card.version }));
      await refresh();
    });
  });
}

// ---------------------------------------------------------------- card drawer

function openDrawerShell() {
  $("#drawer").classList.add("open"); $("#drawer").setAttribute("aria-hidden", "false"); $("#scrim").hidden = false;
}
function closeDrawer(silent) {
  $("#drawer").classList.remove("open"); $("#drawer").setAttribute("aria-hidden", "true"); $("#scrim").hidden = true;
  state.card = null;
  if (!silent && state.view === "board") refresh();
}

async function openCard(id, keepScroll) {
  const card = await attempt(() => api("GET", "/api/cards/" + encodeURIComponent(id)));
  if (!card) return;
  if (!state.board || state.board.id !== card.board_id) {
    state.board = await api("GET", "/api/boards/" + card.board_id);
  }
  state.card = card;
  const scroll = keepScroll ? $("#drawer-body").scrollTop : 0;
  renderCard();
  $("#drawer-body").scrollTop = scroll;
  openDrawerShell();
}

function checklistItems(text) {
  return (text || "").split("\n").map((line, i) => { const m = line.match(/^\s*[-*] \[( |x|X)\] (.*)$/); return m ? { i, done: m[1] !== " ", text: m[2] } : null; }).filter(Boolean);
}

function changeText(ch) {
  const show = (v) => Array.isArray(v) ? (v.length ? v.join(", ") : "none") : (v === null || v === "" ? "none" : String(v));
  const names = { list: "list", title: "title", description: "description", assignee: "assignee", due_date: "due date", labels: "labels", blocked_by: "blocked by" };
  if (ch.field === "description") return "description edited";
  return `${names[ch.field] || ch.field}: <s>${esc(show(ch.from))}</s> → ${esc(show(ch.to))}`;
}

function renderCard() {
  const c = state.card, b = state.board, edit = c.role !== "viewer";
  const items = checklistItems(c.description);
  const plain = (c.description || "").split("\n").filter((l) => !/^\s*[-*] \[( |x|X)\] /.test(l)).join("\n").trim();
  const listOpts = b.lists.map((l) => `<option value="${esc(l.id)}" ${l.id === c.list_id ? "selected" : ""}>${esc(l.name)}</option>`).join("");
  const memberOpts = `<option value="">Nobody</option>` + b.members.map((m) => `<option value="${esc(m.email)}" ${m.email === c.assignee ? "selected" : ""}>${esc(m.email)}</option>`).join("");
  const others = b.lists.flatMap((l) => l.cards).filter((o) => o.id !== c.id && !c.blocked_by.some((x) => x.id === o.id));
  const dis = edit ? "" : "disabled";

  $("#drawer-body").innerHTML = `
    <div class="d-head"><span class="ref">${esc(c.ref)}</span><span>·</span><span>${esc(b.title)}</span>
      ${c.overdue ? `<span class="pill danger">Overdue</span>` : ""}${c.blocked ? `<span class="pill">Blocked</span>` : ""}
      <button class="icon-btn" id="close" aria-label="Close">×</button></div>
    <input class="d-title" id="f-title" value="${esc(c.title)}" ${dis} aria-label="Title">
    <div class="fields">
      <label for="f-list">List</label><select class="select" id="f-list" ${dis}>${listOpts}</select>
      <label for="f-assignee">Assignee</label><select class="select" id="f-assignee" ${dis}>${memberOpts}</select>
      <label for="f-due">Due date</label><input class="input" type="date" id="f-due" value="${esc(c.due_date || "")}" ${dis}>
      <label>Labels</label><div class="label-edit" id="f-labels">${c.labels.map((l) => `<span class="label ${labelClass(l)}">${esc(l)}${edit ? `<button data-rm-label="${esc(l)}" aria-label="Remove ${esc(l)}">×</button>` : ""}</span>`).join("")}
        ${edit ? `<input id="f-label" placeholder="Add label" maxlength="24">` : c.labels.length ? "" : `<span class="prose">None</span>`}</div>
    </div>

    <div class="section"><h4>Description ${edit ? `<button class="btn ghost" id="edit-desc">Edit</button>` : ""}</h4>
      <div id="desc">${plain ? `<div class="prose">${esc(plain)}</div>` : `<div class="prose" style="color:var(--muted)">${edit ? "Add more detail…" : "No description."}</div>`}</div></div>

    <div class="section"><h4>Checklist ${items.length ? `<span>${items.filter((x) => x.done).length}/${items.length}</span>` : ""}
      ${edit ? `<button class="btn" id="suggest">Suggest steps</button>` : ""}</h4>
      ${items.length ? `<div class="progress"><span style="width:${Math.round(100 * items.filter((x) => x.done).length / items.length)}%"></span></div>` : ""}
      <div class="checklist">${items.map((it) => `<label><input type="checkbox" data-line="${it.i}" ${it.done ? "checked" : ""} ${dis}><span>${esc(it.text)}</span></label>`).join("") || `<div class="prose" style="color:var(--muted)">No checklist yet.</div>`}</div>
      <div id="suggestions"></div></div>

    <div class="section"><h4>Blocked by</h4>
      <div class="mini">${c.blocked_by.map((x) => `<div class="item ${x.open ? "" : "done-blocker"}"><span class="ref">${esc(x.ref)}</span><span>${esc(x.title)}</span>${edit ? `<button class="icon-btn" data-unblock="${esc(x.id)}" aria-label="Remove blocker">×</button>` : ""}</div>`).join("") || `<div class="prose" style="color:var(--muted)">Nothing. This card can move freely.</div>`}</div>
      ${edit && others.length ? `<div class="row" style="display:flex;gap:6px;margin-top:8px"><select class="select" id="f-blocker"><option value="">Add a blocking card…</option>${others.map((o) => `<option value="${esc(o.id)}">${esc(o.ref)} · ${esc(o.title)}</option>`).join("")}</select></div>` : ""}
      ${c.blocked ? `<button class="btn ghost" id="why-blocked" style="margin-top:6px">Show the full chain</button><div id="chain"></div>` : ""}
    </div>

    <div class="section"><h4>Similar cards <button class="btn ghost" id="similar">Find</button></h4><div id="similar-out"></div></div>

    <div class="section"><h4>Comments</h4>
      ${edit ? `<form id="comment-form"><textarea class="textarea" style="min-height:64px" id="f-comment" placeholder="Write a comment"></textarea><div style="margin-top:6px"><button class="btn primary">Comment</button></div></form>` : ""}
      ${c.comment_list.slice().reverse().map((m) => `<div class="comment"><div class="who"><b>${esc(m.author)}</b> · ${esc(ago(m.created_at))}</div><div class="prose">${esc(m.body)}</div></div>`).join("")}
    </div>

    <div class="section"><h4>History</h4><ul class="timeline">${c.history.map((e) => `<li><div><b>${esc(e.actor)}</b> ${esc(e.action)}${e.note && e.action !== "commented" ? ` <span class="chg">${esc(e.note)}</span>` : ""}</div>
      ${e.changes.filter((x) => e.action !== "added the card").map((x) => `<div class="chg">${changeText(x)}</div>`).join("")}<div class="when">${esc(ago(e.happened_at))}</div></li>`).join("")}</ul></div>

    ${edit ? `<div class="d-foot"><button class="btn danger" id="archive">Archive card</button></div>` : ""}`;

  $("#close").onclick = () => closeDrawer();
  if (!edit) { wireSimilar(); return; }

  const save = (fields) => attempt(async () => { state.card = { ...state.card, ...(await api("PATCH", "/api/cards/" + c.id, { ...fields, version: state.card.version })) }; await openCard(c.id, true); });
  $("#f-title").onchange = (e) => e.target.value.trim() && save({ title: e.target.value });
  $("#f-title").onkeydown = (e) => { if (e.key === "Enter") e.target.blur(); };
  $("#f-assignee").onchange = (e) => save({ assignee: e.target.value || null });
  $("#f-due").onchange = (e) => save({ due_date: e.target.value || null });
  $("#f-list").onchange = (e) => attempt(async () => { await api("POST", `/api/cards/${c.id}/move`, { list: e.target.value, version: c.version }); await openCard(c.id, true); });
  $$("[data-rm-label]").forEach((btn) => btn.onclick = () => save({ labels: c.labels.filter((l) => l !== btn.dataset.rmLabel) }));
  $("#f-label").onkeydown = (e) => { if (e.key === "Enter" && e.target.value.trim()) save({ labels: [...c.labels, e.target.value.trim()] }); };
  $$(".checklist input").forEach((box) => box.onchange = () => {
    const lines = c.description.split("\n"); const i = +box.dataset.line;
    lines[i] = lines[i].replace(/\[( |x|X)\]/, box.checked ? "[x]" : "[ ]");
    save({ description: lines.join("\n") });
  });
  $("#edit-desc").onclick = () => {
    $("#desc").innerHTML = `<textarea class="textarea" id="f-desc">${esc(c.description)}</textarea><div style="display:flex;gap:6px;margin-top:6px"><button class="btn primary" id="save-desc">Save</button><button class="btn ghost" id="cancel-desc">Cancel</button></div>`;
    $("#f-desc").focus();
    $("#save-desc").onclick = () => save({ description: $("#f-desc").value });
    $("#cancel-desc").onclick = renderCard;
  };
  $("#suggest").onclick = suggestSteps;
  const blocker = $("#f-blocker");
  if (blocker) blocker.onchange = (e) => e.target.value && attempt(async () => { await api("POST", `/api/cards/${c.id}/blockers`, { blocker: e.target.value }); await openCard(c.id, true); });
  $$("[data-unblock]").forEach((btn) => btn.onclick = () => attempt(async () => { await api("DELETE", `/api/cards/${c.id}/blockers/${encodeURIComponent(btn.dataset.unblock)}`); await openCard(c.id, true); }));
  const why = $("#why-blocked");
  if (why) why.onclick = async () => {
    const r = await attempt(() => api("GET", `/api/cards/${c.id}/blocking`));
    if (r) $("#chain").innerHTML = `<div class="mini" style="margin-top:6px">${r.open_blockers.map((x) => `<div class="item" style="margin-left:${(x.depth - 1) * 14}px"><span class="ref">${esc(x.ref)}</span><span>${esc(x.title)}</span><span class="chg" style="margin-left:auto">${esc(x.list || "")}${x.assignee ? " · " + esc(x.assignee.split("@")[0]) : ""}</span></div>`).join("")}</div>`;
  };
  $("#comment-form").onsubmit = (e) => { e.preventDefault(); const body = $("#f-comment").value.trim(); if (body) attempt(async () => { await api("POST", `/api/cards/${c.id}/comments`, { body }); await openCard(c.id, true); }); };
  $("#archive").onclick = () => { if (confirm(`Archive ${c.ref}? Its history is kept.`)) attempt(async () => { await api("DELETE", "/api/cards/" + c.id); closeDrawer(); }, `${c.ref} archived`); };
  wireSimilar();
}

function wireSimilar() {
  const c = state.card;
  $("#similar").onclick = async () => {
    $("#similar-out").innerHTML = `<span class="spin"></span>`;
    const r = await attempt(() => api("GET", `/api/cards/${c.id}/similar`));
    if (!r) { $("#similar-out").innerHTML = ""; return; }
    $("#similar-out").innerHTML = `<div class="mini">${r.similar.map((x) => `<div class="item" data-open="${esc(x.id)}" style="cursor:pointer"><span class="ref">${esc(x.ref)}</span><span>${esc(x.title)}</span><span class="chg" style="margin-left:auto">${esc(x.list)}</span></div>`).join("") || `<div class="prose" style="color:var(--muted)">No close matches on this board.</div>`}</div>
      <div class="source">${r.method === "embeddings" ? "Matched by meaning with Dodil Models embeddings." : "Matched by shared words."}</div>`;
    $$("[data-open]", $("#similar-out")).forEach((el) => el.onclick = () => openCard(el.dataset.open));
  };
}

async function suggestSteps() {
  const c = state.card, out = $("#suggestions");
  out.innerHTML = `<div class="suggest"><span class="spin"></span> Drafting steps with Dodil Models…</div>`;
  const r = await attempt(() => api("POST", `/api/cards/${c.id}/checklist/suggest`));
  if (!r) { out.innerHTML = ""; return; }
  if (!r.steps.length) { out.innerHTML = `<div class="suggest">No steps came back. Try again in a moment.</div>`; return; }
  out.innerHTML = `<div class="suggest">${r.steps.map((s, i) => `<label><input type="checkbox" checked data-step="${i}"><span>${esc(s)}</span></label>`).join("")}
    <div style="display:flex;gap:6px;margin-top:8px"><button class="btn primary" id="apply">Add selected</button><button class="btn ghost" id="discard">Discard</button></div></div>`;
  $("#discard").onclick = () => out.innerHTML = "";
  $("#apply").onclick = () => {
    const steps = $$("[data-step]", out).filter((b) => b.checked).map((b) => r.steps[+b.dataset.step]);
    if (steps.length) attempt(async () => { await api("POST", `/api/cards/${c.id}/checklist`, { steps }); await openCard(c.id, true); }, "Checklist added");
  };
}

async function activityDrawer() {
  const rows = await attempt(() => api("GET", `/api/boards/${state.board.id}/activity?limit=80`));
  if (!rows) return;
  state.card = null;
  $("#drawer-body").innerHTML = `<div class="d-head"><span>${esc(state.board.title)}</span><button class="icon-btn" id="close" aria-label="Close">×</button></div>
    <h2 style="margin:0 0 16px;font-size:20px">History</h2>
    <ul class="timeline">${rows.map((e) => `<li><div><b>${esc(e.actor)}</b> ${esc(e.action)} ${e.card_ref ? `<a href="#" data-card="${esc(e.card_id)}" style="font-family:var(--mono);font-size:12px">${esc(e.card_ref)}</a>` : ""} ${e.note ? `<span class="chg">${esc(e.note)}</span>` : ""}</div>
      ${e.changes.filter(() => e.action !== "added the card").map((x) => `<div class="chg">${changeText(x)}</div>`).join("")}<div class="when">${esc(ago(e.happened_at))}</div></li>`).join("") || `<li>No history yet.</li>`}</ul>`;
  $("#close").onclick = () => closeDrawer();
  $$("[data-card]").forEach((a) => a.onclick = (e) => { e.preventDefault(); openCard(a.dataset.card); });
  openDrawerShell();
}

// ---------------------------------------------------------------- dialogs

function dialog(html, wire) {
  const d = $("#dialog"); $("#dialog-body").innerHTML = html; wire && wire($("#dialog-body")); d.showModal();
  return d;
}

function newBoardDialog() {
  dialog(`<h3>New board</h3><input class="input" id="nb-title" placeholder="e.g. Ops" required maxlength="200">
    <p class="source">It starts with To do, Doing and Done. You can rename, reorder and add lists.</p>
    <div class="actions"><button class="btn ghost" value="cancel" formnovalidate>Cancel</button><button class="btn primary" value="ok">Create board</button></div>`, (f) => {
    f.onsubmit = async (e) => {
      if (e.submitter && e.submitter.value === "cancel") return;
      e.preventDefault();
      const b = await attempt(() => api("POST", "/api/boards", { title: $("#nb-title").value }));
      if (b) { $("#dialog").close(); await loadBoards(); go("#board/" + b.id); }
    };
  });
}

function newListDialog() {
  dialog(`<h3>Add a list</h3><input class="input" id="nl-name" placeholder="e.g. Review" required maxlength="200">
    <div class="actions"><button class="btn ghost" value="cancel" formnovalidate>Cancel</button><button class="btn primary">Add list</button></div>`, (f) => {
    f.onsubmit = async (e) => {
      if (e.submitter && e.submitter.value === "cancel") return;
      e.preventDefault();
      if (await attempt(() => api("POST", `/api/boards/${state.board.id}/lists`, { name: $("#nl-name").value }))) { $("#dialog").close(); refresh(); }
    };
  });
}

function listDialog(listId) {
  const lists = state.board.lists, idx = lists.findIndex((l) => l.id === listId), l = lists[idx];
  dialog(`<h3>List settings</h3>
    <label class="source" for="ls-name">Name</label><input class="input" id="ls-name" value="${esc(l.name)}" maxlength="200">
    <label style="display:flex;gap:8px;align-items:center;margin-top:12px"><input type="checkbox" id="ls-done" ${l.done ? "checked" : ""}> Cards in this list count as done</label>
    <div class="row"><button type="button" class="btn" id="ls-left" ${idx === 0 ? "disabled" : ""}>Move left</button><button type="button" class="btn" id="ls-right" ${idx === lists.length - 1 ? "disabled" : ""}>Move right</button><span style="flex:1"></span><button type="button" class="btn danger" id="ls-del">Delete</button></div>
    <div class="actions"><button class="btn ghost" value="cancel" formnovalidate>Cancel</button><button class="btn primary">Save</button></div>`, (f) => {
    const patch = (body) => attempt(async () => { await api("PATCH", `/api/boards/${state.board.id}/lists/${listId}`, body); $("#dialog").close(); await refresh(); });
    $("#ls-left").onclick = () => patch({ index: idx - 1 });
    $("#ls-right").onclick = () => patch({ index: idx + 1 });
    $("#ls-del").onclick = () => attempt(async () => { await api("DELETE", `/api/boards/${state.board.id}/lists/${listId}`); $("#dialog").close(); await refresh(); });
    f.onsubmit = (e) => {
      if (e.submitter && e.submitter.value === "cancel") return;
      e.preventDefault(); patch({ name: $("#ls-name").value, done: $("#ls-done").checked });
    };
  });
}

function membersDialog() {
  const b = state.board, owner = b.role === "owner";
  dialog(`<h3>Members of ${esc(b.title)}</h3>
    ${b.members.map((m) => `<div class="member">${avatar(m.email)}<span class="email">${esc(m.email)}${m.email === state.me ? " (you)" : ""}</span>
      ${owner && m.role !== "owner" ? `<select class="select" data-role="${esc(m.email)}"><option value="member" ${m.role === "member" ? "selected" : ""}>Member</option><option value="viewer" ${m.role === "viewer" ? "selected" : ""}>Viewer</option></select><button type="button" class="icon-btn" data-remove="${esc(m.email)}" aria-label="Remove">×</button>` : `<span class="pill">${esc(m.role[0].toUpperCase() + m.role.slice(1))}</span>`}</div>`).join("")}
    ${owner ? `<div class="row"><input class="input" id="inv-email" type="email" placeholder="name@company.com"><select class="select" id="inv-role" style="width:auto"><option value="member">Member</option><option value="viewer">Viewer</option></select><button class="btn primary">Invite</button></div>
      <p class="source">Members can edit cards and lists. Viewers can only read. They sign in with this email address.</p>` : `<p class="source">Only the owner can invite people or change roles.</p>`}
    <div class="actions"><button class="btn ghost" value="cancel" formnovalidate>Close</button></div>`, (f) => {
    const after = async () => { await refresh(); membersDialog(); };
    $$("[data-role]", f).forEach((s) => s.onchange = () => attempt(async () => { await api("POST", `/api/boards/${b.id}/members`, { email: s.dataset.role, role: s.value }); await after(); }, "Role updated"));
    $$("[data-remove]", f).forEach((btn) => btn.onclick = () => attempt(async () => { await api("DELETE", `/api/boards/${b.id}/members/${encodeURIComponent(btn.dataset.remove)}`); await after(); }, "Removed"));
    f.onsubmit = (e) => {
      if (e.submitter && e.submitter.value === "cancel") return;
      e.preventDefault();
      const email = $("#inv-email").value.trim(); if (!email) return;
      attempt(async () => { await api("POST", `/api/boards/${b.id}/members`, { email, role: $("#inv-role").value }); await after(); }, "Invited " + email);
    };
  });
}

async function summaryDialog() {
  const d = dialog(`<h3>Daily summary · ${esc(state.board.title)}</h3><div id="sum"><span class="spin"></span> Reading the board…</div>
    <div class="actions"><button class="btn ghost" value="cancel">Close</button></div>`);
  const r = await attempt(() => api("GET", `/api/boards/${state.board.id}/summary`));
  if (!r || !d.open) return;
  $("#sum").innerHTML = `<div class="summary-text">${esc(r.summary)}</div><div class="source">${r.source === "dodil-models" ? "Written by Dodil Models from today's board facts." : "Board facts (the summary model did not answer in time)."}</div>`;
}

// ---------------------------------------------------------------- cross-board views

function rowsHtml(cards) {
  if (!cards.length) return `<div class="rows"><div class="empty">Nothing here.</div></div>`;
  return `<div class="rows">${cards.map((c) => `<div class="rowi" data-card="${esc(c.id)}"><span class="ref">${esc(c.ref)}</span>
    <span>${esc(c.title)} ${c.blocked ? `<span class="pill">Blocked</span>` : ""}</span><span class="where">${esc(c.board)} · ${esc(c.list)}</span>
    <span class="due ${dueClass(c)}">${c.due_date ? esc(dueText(c.due_date)) : ""}</span>${avatar(c.assignee)}</div>`).join("")}</div>`;
}
function wireRows() { $$(".rowi").forEach((r) => r.onclick = () => openCard(r.dataset.card)); }

async function showPlate() {
  state.view = "plate"; state.board = null;
  $("#top").innerHTML = `<h1>My work</h1><span class="grow"></span>`;
  const p = await attempt(() => api("GET", "/api/me/plate?days=7"));
  if (!p) return;
  const none = !p.overdue.length && !p.due_soon.length && !p.no_date_or_later.length;
  $("#view").innerHTML = none && !state.boards.length
    ? `<div class="empty-board"><div><h2>Welcome to Quadro</h2><p>Create a board to start planning with your team.</p><button class="btn primary" id="first-board">Create a board</button></div></div>`
    : `<div class="page"><h3>Overdue</h3>${rowsHtml(p.overdue)}<h3>Due in the next 7 days</h3>${rowsHtml(p.due_soon)}<h3>Later or no date</h3>${rowsHtml(p.no_date_or_later)}</div>`;
  const first = $("#first-board"); if (first) first.onclick = newBoardDialog;
  wireRows();
}

async function showOverdue() {
  state.view = "overdue"; state.board = null;
  $("#top").innerHTML = `<h1>Overdue</h1><span class="grow"></span>`;
  const list = await attempt(() => api("GET", "/api/overdue"));
  if (!list) return;
  $("#view").innerHTML = `<div class="page"><h3>Open cards past their due date, on every board you can see</h3>${rowsHtml(list)}</div>`;
  wireRows();
}

async function showSearch(q) {
  state.view = "search"; state.board = null;
  $("#top").innerHTML = `<h1>Search</h1><input class="search" id="gsearch" value="${esc(q)}" aria-label="Search all boards"><span class="grow"></span>`;
  $("#gsearch").onkeydown = (e) => { if (e.key === "Enter" && e.target.value.trim()) go("#search/" + encodeURIComponent(e.target.value.trim())); };
  const list = await attempt(() => api("GET", "/api/search?q=" + encodeURIComponent(q)));
  if (!list) return;
  $("#view").innerHTML = `<div class="page"><h3>${list.length} result${list.length === 1 ? "" : "s"} for “${esc(q)}”</h3>${rowsHtml(list)}</div>`;
  wireRows();
}

// ---------------------------------------------------------------- start

async function start() {
  const me = await api("GET", "/api/me");
  state.me = me.email;
  $("#me").textContent = me.email;
  $("#new-board").onclick = newBoardDialog;
  $$(".views .nav").forEach((b) => b.onclick = () => go("#" + b.dataset.view));
  $("#scrim").onclick = () => closeDrawer();
  document.addEventListener("keydown", (e) => { if (e.key === "Escape" && $("#drawer").classList.contains("open")) closeDrawer(); });
  window.addEventListener("hashchange", route);
  await loadBoards();
  await route();
}
start().catch((e) => { if (e.message !== "sign in") toast(e.message, true); });