stubborn-otter/otter-boards

Otter Boards, a team task board you can run from the web or from Claude and ChatGPT. FastAPI + DataK3 + appid + Models + MCP.

A remix template · 1 remixes · Remix stubborn-otter/otter-boards

app/boards/doors/static/app.js

// The Otter Boards page. No framework and no build step: read this file, change it, reload.
// Card text is written by other people, so everything reaches the page through h(), which only
// ever creates text nodes. The server enforces every rule; hiding a button here is just manners.

const $ = (selector) => document.querySelector(selector);
const main = $("#main");
const drawer = $("#drawer");
const dialog = $("#dialog");
const toastBox = $("#toast");

const state = {
  board: null, card: null, dragging: null, addTo: null, lastJson: "",
  suggestion: null,   // { cardId, busy, steps: [{ text, keep }] }. Lives here so a redraw keeps it.
};

// ---- small tools ----------------------------------------------------------------------------

function h(tag, props = {}, ...kids) {
  const el = document.createElement(tag);
  el.append(...kids.flat(Infinity).filter((k) => k !== null && k !== undefined && k !== false));
  for (const [key, value] of Object.entries(props)) {
    if (value === undefined || value === null || value === false) continue;
    if (key.startsWith("on")) el.addEventListener(key.slice(2), value);
    else if (key === "class") el.className = value;
    else if (key in el) el[key] = value;
    else el.setAttribute(key, value === true ? "" : value);
  }
  return el;
}

function fill(el, ...kids) {
  el.replaceChildren(...kids.flat(Infinity).filter(Boolean));
}

async function api(method, path, body) {
  const options = { method, headers: {} };
  if (body !== undefined) {
    options.headers["content-type"] = "application/json";
    options.body = JSON.stringify(body);
  }
  const reply = await fetch(path, options);
  if (reply.status === 401) {
    const back = encodeURIComponent(location.pathname + location.hash);
    location.href = `/.dodil/auth/login?return_to=${back}`;
    throw new Error("Sign in to carry on.");
  }
  const data = await reply.json().catch(() => ({}));
  if (!reply.ok) throw new Error(typeof data.detail === "string" ? data.detail : "That didn't work. Try again.");
  return data;
}

let toastTimer;
function toast(text) {
  toastBox.textContent = text;
  toastBox.hidden = false;
  clearTimeout(toastTimer);
  toastTimer = setTimeout(() => { toastBox.hidden = true; }, 4000);
}

// Run a change, show what went wrong if it fails, and bring the board back up to date either way.
async function act(change) {
  try {
    await change();
  } catch (error) {
    toast(error.message);
  }
  await refreshBoard();
  if (state.card) await showCard(state.card.id);
}

function shortDate(iso) {
  const [y, m, d] = iso.split("-").map(Number);
  return new Date(y, m - 1, d).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}

function daysUntil(iso, today) {
  return Math.round((Date.parse(iso) - Date.parse(today)) / 86400000);
}

function dueBadge(card, today) {
  if (!card.due_date) return null;
  const soon = !card.overdue && daysUntil(card.due_date, today) <= 2 && !isFinished(card);
  const name = card.overdue ? "due late" : soon ? "due soon" : "due";
  return h("span", { class: name, title: `Due ${card.due_date}` },
    `${card.overdue ? "Overdue, " : ""}${shortDate(card.due_date)}`);
}

function isFinished(card) {
  return state.board?.lists.find((x) => x.id === card.list_id)?.is_done;
}

function cardLink(card) {
  return `#/b/${card.board_id}/c/${card.id}`;
}

// ---- routes ---------------------------------------------------------------------------------

async function route() {
  const parts = location.hash.replace(/^#\/?/, "").split("/");
  closeDrawer();
  if (dialog.open) dialog.close();
  if (parts[0] === "b" && parts[1]) {
    await openBoard(parts[1]);
    if (parts[2] === "c" && parts[3] && state.board) await showCard(parts[3]);
  } else {
    state.board = null;
    await showHome();
  }
}

// ---- home: your boards and your week --------------------------------------------------------

async function showHome() {
  fill($("#crumbs"));
  const [boards, plate] = await Promise.all([api("GET", "/api/boards"), api("GET", "/api/me/cards")]);
  const form = h("form", { class: "row new-board", onsubmit: createBoard },
    h("input", { name: "title", placeholder: "Name a new board", "aria-label": "New board name", required: true }),
    h("button", {}, "Create board"));
  fill(main, h("div", { class: "home" },
    h("section", {},
      h("h1", {}, "Your boards"),
      boards.length ? null : h("p", { class: "quiet" }, "No boards yet. Name your first one to start."),
      h("div", { class: "tiles" }, boards.map((b) => h("a", { class: "tile", href: `#/b/${b.id}` }, b.title,
        h("small", {}, b.role)))),
      form),
    h("section", {},
      h("h2", {}, "My week"),
      plateView(plate))));
}

function plateView(plate) {
  const groups = [["Overdue", plate.overdue, true], ["Next 7 days", plate.due_soon], ["No due date", plate.no_due_date]]
    .filter(([, cards]) => cards.length);
  if (!groups.length) return h("p", { class: "quiet" }, "Nothing is assigned to you this week.");
  return h("div", { class: "plate" }, groups.map(([label, cards, alarm]) => h("div", {},
    h("h3", { class: alarm ? "alarm" : "" }, label),
    cards.map((c) => h("a", { class: "plate-row", href: cardLink(c) },
      h("span", {}, c.title),
      h("small", {}, `${c.board}, ${c.list}`),
      c.due_date ? h("small", {}, shortDate(c.due_date)) : null)))));
}

async function createBoard(event) {
  event.preventDefault();
  const title = event.target.elements.title.value;
  try {
    const board = await api("POST", "/api/boards", { title });
    location.hash = `#/b/${board.id}`;
  } catch (error) {
    toast(error.message);
  }
}

// ---- board ----------------------------------------------------------------------------------

async function openBoard(id) {
  try {
    state.board = await api("GET", `/api/boards/${id}`);
    state.lastJson = JSON.stringify(state.board);
    renderBoard();
  } catch (error) {
    state.board = null;
    fill($("#crumbs"));
    fill(main, h("div", { class: "home" }, h("h1", {}, "That board isn't here"),
      h("p", { class: "quiet" }, "It doesn't exist, or you aren't on it. Ask its owner to invite you."),
      h("p", {}, h("a", { href: "#/" }, "Back to your boards"))));
  }
}

async function refreshBoard() {
  if (!state.board) return;
  const fresh = await api("GET", `/api/boards/${state.board.id}`).catch(() => null);
  if (!fresh) return;
  const json = JSON.stringify(fresh);
  if (json === state.lastJson) return;
  state.board = fresh;
  state.lastJson = json;
  renderBoard();
}

function renderBoard() {
  const b = state.board;
  fill($("#crumbs"), b.title);
  const title = b.is_owner
    ? h("input", { class: "board-title", value: b.title, "aria-label": "Board name",
        onchange: (e) => act(() => api("PATCH", `/api/boards/${b.id}`, { title: e.target.value })) })
    : h("h1", { class: "board-title" }, b.title);
  const lists = h("div", { class: "lists" },
    b.lists.map((x, i) => renderList(x, i)), b.can_edit ? addListForm() : null);
  const scroll = main.querySelector(".lists")?.scrollLeft ?? 0;
  fill(main,
    h("div", { class: "board-head" }, title,
      h("span", { class: "pill" }, b.role),
      b.overdue_count ? h("span", { class: "pill alarm" }, `${b.overdue_count} overdue`) : null,
      h("span", { class: "spacer" }),
      h("button", { class: "plain", onclick: showMembers }, b.is_owner ? "Manage people" : "People"),
      h("button", { class: "plain", onclick: showSummary }, "Today's summary")),
    b.can_edit ? null : h("p", { class: "read-only" }, "You can read this board. Ask the owner if you need to change it."),
    lists);
  lists.scrollLeft = scroll;
  if (state.addTo) {
    main.querySelector(`[data-add="${CSS.escape(state.addTo)}"]`)?.focus();
    state.addTo = null;
  }
}

function renderList(list, index) {
  const b = state.board;
  const cards = h("div", { class: "cards" }, list.cards.map((c) => renderCard(c)));
  const section = h("section", { class: "list", "aria-label": list.name }, listHead(list, index),
    list.is_done ? h("div", { class: "done-mark" }, "Cards here count as finished") : null,
    cards, b.can_edit ? addCardForm(list) : null);
  if (b.can_edit) {
    section.addEventListener("dragover", (event) => dragOver(event, section, cards));
    section.addEventListener("dragleave", (event) => {
      if (!section.contains(event.relatedTarget)) clearDrop();
    });
    section.addEventListener("drop", (event) => drop(event, list));
  }
  return section;
}

function listHead(list, index) {
  const b = state.board;
  const bid = b.id;
  const url = `/api/boards/${bid}/lists/${list.id}`;
  if (!b.can_edit) {
    return h("div", { class: "list-head" }, h("h2", { class: "list-name" }, list.name),
      h("span", { class: "count" }, list.cards.length));
  }
  const neighbour = (i) => b.lists[i]?.id ?? null;
  return h("div", { class: "list-head" },
    h("input", { class: "list-name", value: list.name, "aria-label": `Rename list ${list.name}`,
      onchange: (e) => act(() => api("PATCH", url, { name: e.target.value })) }),
    h("span", { class: "count" }, list.cards.length),
    h("details", { class: "menu" },
      h("summary", { "aria-label": `Options for ${list.name}` }, "..."),
      h("div", { class: "menu-items" },
        index > 0 ? h("button", { onclick: () => act(() => api("POST", `${url}/move`, { before: neighbour(index - 1) })) }, "Move left") : null,
        index < b.lists.length - 1 ? h("button", { onclick: () => act(() => api("POST", `${url}/move`, { before: neighbour(index + 2) })) }, "Move right") : null,
        h("button", { onclick: () => act(() => api("PATCH", url, { is_done: !list.is_done })) },
          list.is_done ? "Count cards here as open" : "Count cards here as finished"),
        h("button", { onclick: () => act(() => api("POST", `${url}/archive`)) }, "Archive list"))));
}

function renderCard(card) {
  const b = state.board;
  const done = card.checklist.filter((i) => i.done).length;
  const el = h("article", {
    class: `card${card.overdue ? " is-overdue" : ""}`, draggable: b.can_edit, tabIndex: 0, "data-card": card.id,
    onclick: () => { location.hash = cardLink(card); },
    onkeydown: (e) => { if (e.key === "Enter") location.hash = cardLink(card); },
  },
    card.labels.length ? h("div", { class: "tags" }, card.labels.map((l) => h("span", { class: "tag" }, l))) : null,
    h("div", { class: "card-title" }, card.title),
    h("div", { class: "card-foot" },
      h("span", {}, `#${card.number}`),
      dueBadge(card, b.today),
      card.blocked ? h("span", { class: "blocked" }, "Blocked") : null,
      card.checklist.length ? h("span", {}, `${done}/${card.checklist.length}`) : null,
      card.assignee ? h("span", { class: "face", title: card.assignee }, card.assignee.slice(0, 2)) : null));
  el.addEventListener("dragstart", (event) => {
    state.dragging = card;
    event.dataTransfer.setData("text/plain", card.id);
    event.dataTransfer.effectAllowed = "move";
    el.classList.add("dragging");
  });
  el.addEventListener("dragend", () => {
    state.dragging = null;
    el.classList.remove("dragging");
    clearDrop();
  });
  return el;
}

// ---- drag and drop --------------------------------------------------------------------------

const dropLine = h("div", { class: "drop-line" });

function clearDrop() {
  dropLine.remove();
  document.querySelectorAll(".list.over").forEach((el) => el.classList.remove("over"));
}

function dragOver(event, section, cards) {
  if (!state.dragging) return;
  event.preventDefault();
  event.dataTransfer.dropEffect = "move";
  document.querySelectorAll(".list.over").forEach((el) => { if (el !== section) el.classList.remove("over"); });
  section.classList.add("over");
  const next = [...cards.querySelectorAll(".card:not(.dragging)")].find((el) => {
    const box = el.getBoundingClientRect();
    return event.clientY < box.top + box.height / 2;
  });
  if (next) cards.insertBefore(dropLine, next);
  else cards.append(dropLine);
}

function drop(event, list) {
  event.preventDefault();
  const card = state.dragging;
  if (!card) return;
  const before = dropLine.nextElementSibling?.dataset.card ?? null;
  const el = main.querySelector(`[data-card="${CSS.escape(card.id)}"]`);
  if (el) dropLine.replaceWith(el);   // show it landing now; the refresh below confirms it
  clearDrop();
  state.dragging = null;
  act(() => api("POST", `/api/cards/${card.id}/move`, { list_name: list.id, before }));
}

// ---- adding -------------------------------------------------------------------------------

function addCardForm(list) {
  return h("form", { class: "add-card", onsubmit: (event) => {
    event.preventDefault();
    const input = event.target.elements.title;
    const title = input.value;
    if (!title.trim()) return;
    input.value = "";
    state.addTo = list.id;
    act(() => api("POST", `/api/boards/${state.board.id}/cards`, { title, list_name: list.id }));
  } }, h("input", { name: "title", placeholder: "Add a card", "aria-label": `Add a card to ${list.name}`, "data-add": list.id }));
}

function addListForm() {
  return h("form", { class: "add-list", onsubmit: (event) => {
    event.preventDefault();
    const name = event.target.elements.name.value;
    if (name.trim()) act(() => api("POST", `/api/boards/${state.board.id}/lists`, { name }));
  } }, h("input", { name: "name", placeholder: "Add a list", "aria-label": "Add a list" }));
}

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

function closeDrawer() {
  state.card = null;
  state.suggestion = null;
  drawer.hidden = true;
}

function leaveCard() {
  closeDrawer();
  location.hash = `#/b/${state.board.id}`;
}

async function showCard(id) {
  try {
    state.card = await api("GET", `/api/cards/${encodeURIComponent(id)}`);
  } catch (error) {
    toast(error.message);
    leaveCard();
    return;
  }
  if (state.card.board_id !== state.board.id) return;
  renderDrawer(state.card);
}

function renderDrawer(card) {
  const b = state.board;
  const url = `/api/cards/${card.id}`;
  const locked = !b.can_edit;
  const edit = (fields) => act(() => api("PATCH", url, fields));
  const doers = b.members.filter((m) => m.role !== "viewer");
  // Don't rebuild the drawer under someone who is typing in it.
  const typing = drawer.contains(document.activeElement) && document.activeElement.matches("input:not([type=checkbox]), textarea");
  if (typing && !drawer.hidden && drawer.dataset.card === card.id) return;
  drawer.dataset.card = card.id;

  const others = b.lists.flatMap((x) => x.cards).filter((c) => c.id !== card.id && !card.blocked_by.includes(c.number));
  fill(drawer,
    h("div", { class: "drawer-head" },
      h("span", { class: "number" }, `#${card.number}`),
      h("input", { class: "drawer-title", value: card.title, disabled: locked, "aria-label": "Card title",
        onchange: (e) => edit({ title: e.target.value }) }),
      h("button", { class: "plain", onclick: leaveCard }, "Close")),
    h("div", { class: "pair" },
      h("label", {}, "List", h("select", { disabled: locked, value: card.list_id,
        onchange: (e) => act(() => api("POST", `${url}/move`, { list_name: e.target.value })) },
        b.lists.map((x) => h("option", { value: x.id, selected: x.id === card.list_id }, x.name)))),
      h("label", {}, "Assignee", h("select", { disabled: locked, value: card.assignee ?? "",
        onchange: (e) => act(() => api("POST", `${url}/assign`, { assignee: e.target.value || null })) },
        h("option", { value: "", selected: !card.assignee }, "Nobody"),
        doers.map((m) => h("option", { value: m.email, selected: m.email === card.assignee }, m.email)))),
      h("label", {}, "Due date", h("input", { type: "date", value: card.due_date ?? "", disabled: locked,
        onchange: (e) => edit({ due_date: e.target.value || null }) })),
      h("label", {}, "Labels", h("input", { value: card.labels.join(", "), disabled: locked, placeholder: "finance, urgent",
        onchange: (e) => edit({ labels: e.target.value.split(",") }) }))),
    card.overdue ? h("p", { class: "blocked" }, "This card is overdue.") : null,
    h("label", {}, "Description", h("textarea", { value: card.description, disabled: locked,
      onchange: (e) => edit({ description: e.target.value }) })),
    checklistPart(card, url, locked),
    blockersPart(card, url, locked, others),
    similarPart(card, url),
    historyPart(card),
    locked ? null : h("div", { class: "actions" }, h("button", { class: "danger", onclick: () => {
      leaveCard();
      act(() => api("POST", `${url}/archive`));
    } }, "Archive card")));
  drawer.hidden = false;
}

function checklistPart(card, url, locked) {
  const done = card.checklist.filter((i) => i.done).length;
  const save = (items) => act(() => api("PUT", `${url}/checklist`, { items }));
  const suggestion = state.suggestion?.cardId === card.id ? state.suggestion : null;
  return h("section", { class: "part" },
    h("h3", {}, `Checklist${card.checklist.length ? ` (${done} of ${card.checklist.length})` : ""}`),
    card.checklist.length ? h("div", { class: "bar" }, progressBar(done, card.checklist.length)) : null,
    card.checklist.map((item, i) => h("div", { class: `check-row${item.done ? " ticked" : ""}` },
      h("input", { type: "checkbox", checked: item.done, disabled: locked, "aria-label": item.text,
        onchange: (e) => act(() => api("POST", `${url}/checklist/tick`, { item: String(i + 1), done: e.target.checked })) }),
      h("span", {}, item.text),
      locked ? null : h("button", { class: "plain tiny", "aria-label": `Remove ${item.text}`,
        onclick: () => save(card.checklist.filter((_, n) => n !== i)) }, "Remove"))),
    locked ? null : h("form", { class: "row", onsubmit: (event) => {
      event.preventDefault();
      const text = event.target.elements.item.value;
      if (text.trim()) save([...card.checklist, { text, done: false }]);
    } },
      h("input", { name: "item", placeholder: "Add a step", "aria-label": "Add a step" }),
      h("button", { type: "button", class: "plain", disabled: suggestion?.busy, onclick: () => suggestSteps(card, url) },
        suggestion?.busy ? "Thinking" : "Suggest steps")),
    suggestion?.steps ? suggestedSteps(card, suggestion, save) : null);
}

function suggestedSteps(card, suggestion, save) {
  return h("div", { class: "part" },
    h("h3", {}, "Suggested steps"),
    h("p", { class: "quiet" }, "Untick what you don't want, then add the rest to the card."),
    suggestion.steps.map((step) => h("label", { class: "check-row" },
      h("input", { type: "checkbox", checked: step.keep, "aria-label": step.text, onchange: (e) => { step.keep = e.target.checked; } }),
      h("span", {}, step.text))),
    h("div", { class: "actions" }, h("button", { onclick: () => {
      const kept = suggestion.steps.filter((step) => step.keep).map(({ text }) => ({ text, done: false }));
      state.suggestion = null;
      save([...card.checklist, ...kept]);
    } }, "Add to card")));
}

function progressBar(done, total) {
  const bar = h("div", {});
  bar.style.width = `${Math.round((done / total) * 100)}%`;
  return bar;
}

async function suggestSteps(card, url) {
  state.suggestion = { cardId: card.id, busy: true };
  renderDrawer(state.card);
  try {
    const { suggested_checklist: steps } = await api("POST", `${url}/checklist/suggest`);
    if (state.suggestion?.cardId !== card.id) return;  // they closed the card while it thought
    state.suggestion = { cardId: card.id, steps: steps.map((text) => ({ text, keep: true })) };
  } catch (error) {
    state.suggestion = null;
    toast(error.message);
  }
  if (state.card?.id === card.id) renderDrawer(state.card);
}

function blockersPart(card, url, locked, others) {
  const b = state.board;
  const all = b.lists.flatMap((x) => x.cards);
  const blockers = card.blocked_by.map((n) => all.find((c) => c.number === n)).filter(Boolean);
  return h("section", { class: "part" },
    h("h3", {}, "Blocked by"),
    blockers.length ? null : h("p", { class: "quiet" }, "Nothing is holding this card up."),
    blockers.map((c) => h("div", { class: "link-row" },
      h("a", { href: cardLink(c) }, `#${c.number} ${c.title}`),
      isFinished(c) ? h("span", { class: "pill" }, "finished") : null,
      locked ? null : h("button", { class: "plain tiny", onclick: () => act(() => api("DELETE", `${url}/blockers/${c.id}`)) }, "Remove"))),
    locked || !others.length ? null : h("select", { "aria-label": "Add a card that blocks this one",
      onchange: (e) => { if (e.target.value) act(() => api("POST", `${url}/blockers`, { blocked_by: e.target.value })); } },
      h("option", { value: "" }, "Add a blocker"),
      others.map((c) => h("option", { value: c.id }, `#${c.number} ${c.title}`))),
    card.blocking.length ? h("p", { class: "quiet" }, "Holding up: ", card.blocking.map((c) => `#${c.number} ${c.title}`).join(", ")) : null);
}

function similarPart(card, url) {
  const box = h("div", {});
  return h("section", { class: "part" },
    h("h3", {}, "Cards like this one"),
    h("div", {}, h("button", { class: "plain", onclick: async (e) => {
      e.target.disabled = true;
      fill(box, h("p", { class: "quiet" }, "Searching by meaning. The first search can take a minute or two while the AI wakes up."));
      try {
        const found = await api("GET", `${url}/similar`);
        fill(box, found.matches.length
          ? found.matches.map((m) => h("div", { class: "link-row" },
            h("a", { href: `#/b/${card.board_id}/c/${m.id}` }, `#${m.number} ${m.title}`),
            h("span", { class: "pill" }, m.list)))
          : h("p", { class: "quiet" }, "No close matches on this board."),
        found.method === "keywords" ? h("p", { class: "quiet" }, "Matched on shared words, because AI isn't answering.") : null);
      } catch (error) {
        fill(box);
        toast(error.message);
      }
      e.target.disabled = false;
    } }, "Find similar cards")),
    box);
}

function historyPart(card) {
  return h("section", { class: "part" },
    h("h3", {}, "History"),
    h("ol", { class: "history" }, [...card.history].reverse().map((e) => h("li", {},
      h("b", {}, e.who), " ", e.action,
      h("small", {}, new Date(e.at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }))))));
}

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

function openDialog(...kids) {
  fill(dialog, h("div", { class: "dialog-body" }, kids,
    h("div", { class: "actions" }, h("button", { class: "plain", onclick: () => dialog.close() }, "Close"))));
  if (!dialog.open) dialog.showModal();
}

async function showMembers() {
  const b = state.board;
  const url = `/api/boards/${b.id}/members`;
  const people = await api("GET", url);
  const redo = async (change) => {
    try {
      await change();
      await refreshBoard();
      await showMembers();
    } catch (error) {
      toast(error.message);
    }
  };
  openDialog(
    h("h2", {}, b.is_owner ? "Manage people" : "People"),
    h("div", { class: "people" }, people.map((p) => h("div", { class: "person" },
      h("span", {}, p.email),
      !b.is_owner || p.role === "owner" ? h("span", { class: "pill" }, p.role) : h("select", {
        "aria-label": `Role for ${p.email}`,
        onchange: (e) => redo(() => api("POST", url, { email: p.email, role: e.target.value })) },
      ["member", "viewer"].map((r) => h("option", { value: r, selected: r === p.role }, r))),
      b.is_owner && p.role !== "owner" ? h("button", { class: "plain tiny",
        onclick: () => redo(() => api("DELETE", `${url}/${encodeURIComponent(p.email)}`)) }, "Remove") : null))),
    b.is_owner ? h("form", { class: "row", onsubmit: (event) => {
      event.preventDefault();
      const f = event.target.elements;
      redo(() => api("POST", url, { email: f.email.value, role: f.role.value }));
    } },
      h("input", { name: "email", type: "email", placeholder: "Email address", required: true, "aria-label": "Email to invite" }),
      h("select", { name: "role", "aria-label": "Role" }, h("option", { value: "member" }, "member"), h("option", { value: "viewer" }, "viewer")),
      h("button", {}, "Invite")) : null,
    b.is_owner ? h("p", { class: "quiet" }, "Invited people see this board once they sign in with that email. Send them the link yourself.") : null);
}

async function showSummary() {
  openDialog(h("h2", {}, "Today's summary"), h("p", { class: "quiet" }, "Reading the board."));
  try {
    const s = await api("GET", `/api/boards/${state.board.id}/summary`);
    openDialog(h("h2", {}, "Today's summary"),
      h("p", { class: "summary-text" }, s.summary),
      s.source === "facts" ? h("p", { class: "quiet" }, "These are the plain facts, because AI isn't answering.") : null);
  } catch (error) {
    openDialog(h("h2", {}, "Today's summary"), h("p", {}, error.message));
  }
}

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

document.addEventListener("keydown", (event) => {
  if (event.key === "Escape" && !drawer.hidden && !dialog.open) leaveCard();
});
document.addEventListener("click", (event) => {
  document.querySelectorAll("details.menu[open]").forEach((menu) => {
    if (!menu.contains(event.target)) menu.open = false;
  });
});
window.addEventListener("hashchange", route);

// Keep the board current, so a card Claude moves appears here without a reload.
setInterval(() => {
  const busy = state.dragging || document.hidden || dialog.open
    || (document.activeElement && document.activeElement.matches("input, textarea, select"));
  if (state.board && !busy) refreshBoard();
}, 15000);

api("GET", "/api/me").then((me) => {
  $("#who").textContent = me.email;
  return route();
}).catch((error) => toast(error.message));