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));