dachdigitalwerksorg/kanvaflow

KanvaFlow — Remixable team task board with RBAC, drag-and-drop Kanban, dependencies, Dodil Models AI, and MCP tools.

A remix template · 0 remixes · Remix dachdigitalwerksorg/kanvaflow

app/boards/doors/web.py

"""The Web Door: JSON REST routes + KanvaFlow single-page Kanban UI.

Every route delegates strictly to `Boards` use cases in `application/use_cases.py`.
"""
from typing import Optional

from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel, Field

from boards.application.use_cases import Boards, NotFound
from boards.domain.model import Forbidden, Invalid, Person
from boards.doors.identity import person_from


def build_router(boards: Boards) -> APIRouter:
    r = APIRouter()

    def me(request: Request) -> Person:
        p = person_from(request.headers)
        if p is None:
            raise HTTPException(401, "sign in first: /.dodil/auth/login")
        return p

    def run(fn, *args, **kwargs):
        try:
            return fn(*args, **kwargs)
        except NotFound as exc:
            raise HTTPException(404, str(exc)) from exc
        except Forbidden as exc:
            raise HTTPException(403, str(exc)) from exc
        except Invalid as exc:
            raise HTTPException(422, str(exc)) from exc

    class BoardIn(BaseModel):
        title: str
        description: str = ""

    class ListIn(BaseModel):
        name: str
        is_done: bool = False

    class MemberIn(BaseModel):
        email: str
        role: str = "member"

    class CardIn(BaseModel):
        title: str
        list_name: str = "To do"
        description: str = ""
        assignee: Optional[str] = None
        due_date: Optional[str] = None
        labels: list[str] = Field(default_factory=list)

    class CardPatch(BaseModel):
        title: Optional[str] = None
        description: Optional[str] = None
        assignee: Optional[str] = None
        due_date: Optional[str] = None
        labels: Optional[list[str]] = None
        checklist: Optional[list[dict]] = None

    class MoveIn(BaseModel):
        list_name: str
        position: Optional[int] = None

    class AssignIn(BaseModel):
        assignee: Optional[str] = None

    class BlockerIn(BaseModel):
        blocked_by_id: str

    class ChecklistIn(BaseModel):
        apply: bool = False

    @r.get("/api/me")
    def whoami(request: Request):
        p = me(request)
        return {"email": p.email}

    @r.get("/api/agenda")
    def my_agenda(request: Request, days: int = Query(7, ge=1, le=60)):
        return run(boards.my_agenda, me(request), days=days)

    @r.get("/api/overdue")
    def list_overdue(request: Request, board_id: Optional[str] = None):
        return run(boards.list_overdue, me(request), board_id=board_id)

    @r.get("/api/search")
    def search_cards(
        request: Request,
        q: str = Query(..., min_length=1),
        board_id: Optional[str] = None,
        limit: int = Query(6, ge=1, le=20),
    ):
        return run(boards.search_similar_cards, me(request), q, board_id=board_id, limit=limit)

    @r.get("/api/blockers")
    def what_blocks(request: Request, target: str = Query(..., min_length=1), board_id: Optional[str] = None):
        return run(boards.what_is_blocking, me(request), target, board_id=board_id)

    @r.get("/api/boards")
    def my_boards(request: Request):
        return run(boards.my_boards, me(request))

    @r.post("/api/boards", status_code=201)
    def create_board(body: BoardIn, request: Request):
        return run(boards.create_board, me(request), body.title, body.description)

    @r.get("/api/boards/{board_id}")
    def show_board(board_id: str, request: Request):
        return run(boards.show_board, me(request), board_id)

    @r.get("/api/boards/{board_id}/activity")
    def board_activity(board_id: str, request: Request, limit: int = Query(40, ge=1, le=200)):
        return run(boards.board_activity, me(request), board_id, limit=limit)

    @r.post("/api/boards/{board_id}/summary")
    def summarize_board(board_id: str, request: Request):
        return run(boards.summarize_board, me(request), board_id)

    @r.post("/api/boards/{board_id}/lists", status_code=201)
    def add_list(board_id: str, body: ListIn, request: Request):
        return run(boards.add_list, me(request), board_id, body.name, body.is_done)

    @r.delete("/api/boards/{board_id}/lists/{list_name}")
    def remove_list(board_id: str, list_name: str, request: Request):
        return run(boards.remove_list, me(request), board_id, list_name)

    @r.post("/api/boards/{board_id}/members", status_code=201)
    def add_member(board_id: str, body: MemberIn, request: Request):
        return run(boards.add_member, me(request), board_id, body.email, body.role)

    @r.delete("/api/boards/{board_id}/members/{email}")
    def remove_member(board_id: str, email: str, request: Request):
        return run(boards.remove_member, me(request), board_id, email)

    @r.post("/api/boards/{board_id}/cards", status_code=201)
    def add_card(board_id: str, body: CardIn, request: Request):
        return run(
            boards.add_card,
            me(request),
            board_id,
            body.title,
            list_name=body.list_name,
            description=body.description,
            assignee=body.assignee,
            due_date=body.due_date,
            labels=body.labels,
        )

    @r.patch("/api/cards/{card_id}")
    def update_card(card_id: str, body: CardPatch, request: Request):
        fields = body.model_dump(exclude_unset=True)
        return run(boards.update_card, me(request), card_id, **fields)

    @r.post("/api/cards/{card_id}/assign")
    def assign_card(card_id: str, body: AssignIn, request: Request):
        return run(boards.assign_card, me(request), card_id, body.assignee)

    @r.post("/api/cards/{card_id}/move")
    def move_card(card_id: str, body: MoveIn, request: Request):
        return run(boards.move_card, me(request), card_id, body.list_name, position=body.position)

    @r.delete("/api/cards/{card_id}")
    def delete_card(card_id: str, request: Request):
        return run(boards.delete_card, me(request), card_id)

    @r.get("/api/cards/{card_id}/history")
    def card_history(card_id: str, request: Request):
        return run(boards.card_history, me(request), card_id)

    @r.post("/api/cards/{card_id}/blockers", status_code=201)
    def add_blocker(card_id: str, body: BlockerIn, request: Request):
        return run(boards.add_blocker, me(request), card_id, body.blocked_by_id)

    @r.delete("/api/cards/{card_id}/blockers/{blocked_by_id}")
    def remove_blocker(card_id: str, blocked_by_id: str, request: Request):
        return run(boards.remove_blocker, me(request), card_id, blocked_by_id)

    @r.post("/api/cards/{card_id}/checklist")
    def suggest_checklist(card_id: str, request: Request, body: Optional[ChecklistIn] = None):
        apply_flag = bool(body.apply) if body else False
        return run(boards.suggest_checklist, me(request), card_id, apply=apply_flag)

    @r.post("/", include_in_schema=False)
    def ignite_self_check():
        """Used by `dodil ignite invoke <app>` to verify live DataK3 credentials & schema."""
        return boards.self_check()

    @r.get("/", response_class=HTMLResponse)
    def page(request: Request):
        if person_from(request.headers) is None:
            return RedirectResponse("/.dodil/auth/login?return_to=/", status_code=303)
        return PAGE

    return r


PAGE = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KanvaFlow — Team Boards on Dodil</title>
<style>
  :root {
    --bg: #0f172a;
    --surface: #1e293b;
    --surface-2: #263349;
    --card-bg: #ffffff;
    --col-bg: #f1f5f9;
    --text: #0f172a;
    --muted: #64748b;
    --border: #e2e8f0;
    --accent: #4f46e5;
    --accent-hover: #4338ca;
    --emerald: #059669;
    --danger: #dc2626;
    --amber: #d97706;
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
    background: #f8fafc; color: var(--text); min-height: 100vh; display: flex; flex-direction: column;
  }
  /* TOPBAR */
  .topbar {
    background: var(--bg); color: #fff; height: 58px; padding: 0 20px;
    display: flex; align-items: center; justify-content: space-between; gap: 14px;
    box-shadow: 0 2px 10px rgba(15,23,42,0.18); position: sticky; top: 0; z-index: 40;
  }
  .brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1.05rem; letter-spacing: -0.01em; }
  .brand-icon {
    width: 30px; height: 30px; border-radius: 7px;
    background: linear-gradient(135deg, #6366f1, #10b981);
    display: grid; place-items: center; font-weight: 800; font-size: 0.9rem; color: #fff;
  }
  .search-wrap { flex: 1; max-width: 420px; display: flex; gap: 6px; }
  .search-wrap input {
    width: 100%; background: var(--surface); border: 1px solid #334155; color: #fff;
    padding: 7px 12px; border-radius: 6px; font-size: 0.85rem;
  }
  .search-wrap input::placeholder { color: #94a3b8; }
  .top-right { display: flex; align-items: center; gap: 10px; font-size: 0.83rem; }
  .user-pill { background: var(--surface); padding: 5px 11px; border-radius: 99px; color: #cbd5e1; border: 1px solid #334155; }
  .btn {
    border: none; border-radius: 6px; padding: 7px 13px; font-size: 0.83rem; font-weight: 600;
    cursor: pointer; transition: all 0.15s ease; display: inline-flex; align-items: center; gap: 6px;
  }
  .btn-primary { background: var(--accent); color: #fff; }
  .btn-primary:hover { background: var(--accent-hover); }
  .btn-ghost { background: #fff; color: var(--text); border: 1px solid var(--border); }
  .btn-ghost:hover { background: #f1f5f9; }
  .btn-dark { background: var(--surface); color: #e2e8f0; border: 1px solid #334155; }
  .btn-dark:hover { background: var(--surface-2); }
  .btn-danger { background: #fee2e2; color: var(--danger); }
  .btn-sm { padding: 4px 9px; font-size: 0.76rem; }

  /* LAYOUT */
  .workspace { display: flex; flex: 1; min-height: calc(100vh - 58px); }
  .sidebar {
    width: 250px; background: #fff; border-right: 1px solid var(--border);
    padding: 16px; display: flex; flex-direction: column; gap: 14px; flex-shrink: 0;
  }
  .side-title { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
  .board-nav { list-style: none; display: flex; flex-direction: column; gap: 4px; }
  .board-nav li button {
    width: 100%; text-align: left; background: transparent; border: none; padding: 9px 10px;
    border-radius: 6px; cursor: pointer; font-size: 0.88rem; font-weight: 500;
    display: flex; justify-content: space-between; align-items: center; color: var(--text);
  }
  .board-nav li button:hover { background: #f1f5f9; }
  .board-nav li button.active { background: #eef2ff; color: var(--accent); font-weight: 700; }
  .role-tag { font-size: 0.68rem; padding: 2px 6px; border-radius: 4px; background: #e2e8f0; color: #334155; text-transform: uppercase; }
  .new-board-box { display: flex; flex-direction: column; gap: 6px; padding-top: 10px; border-top: 1px solid var(--border); }
  .input {
    width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px;
    font-size: 0.86rem; font-family: inherit; background: #fff;
  }

  /* MAIN STAGE */
  .stage { flex: 1; padding: 20px 24px; overflow-x: auto; display: flex; flex-direction: column; gap: 16px; }
  .board-header {
    display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px;
    background: #fff; padding: 14px 18px; border-radius: 10px; border: 1px solid var(--border);
  }
  .board-title-wrap h1 { font-size: 1.3rem; font-weight: 700; display: flex; align-items: center; gap: 10px; }
  .board-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
  .banner {
    background: #eef2ff; border: 1px solid #c7d2fe; color: #1e1b4b;
    padding: 14px 16px; border-radius: 8px; font-size: 0.88rem; line-height: 1.5;
    display: none; justify-content: space-between; align-items: flex-start; gap: 12px;
  }

  /* KANBAN COLUMNS */
  .columns { display: flex; gap: 16px; align-items: flex-start; overflow-x: auto; padding-bottom: 24px; flex: 1; }
  .column {
    width: 305px; flex-shrink: 0; background: var(--col-bg); border-radius: 10px;
    border: 1px solid var(--border); padding: 12px; display: flex; flex-direction: column; gap: 10px;
    max-height: calc(100vh - 190px);
  }
  .column.drag-over { outline: 2px dashed var(--accent); background: #eef2ff; }
  .col-head { display: flex; justify-content: space-between; align-items: center; font-weight: 700; font-size: 0.88rem; padding: 2px 4px; }
  .col-count { background: #cbd5e1; color: #1e293b; border-radius: 99px; padding: 1px 8px; font-size: 0.74rem; }
  .card-list { display: flex; flex-direction: column; gap: 8px; overflow-y: auto; min-height: 38px; padding: 2px; }
  .card {
    background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px;
    padding: 11px 12px; box-shadow: 0 1px 2px rgba(15,23,42,0.04); cursor: pointer;
    transition: transform 0.12s ease, box-shadow 0.12s ease;
  }
  .card:hover { transform: translateY(-1px); box-shadow: 0 4px 10px rgba(15,23,42,0.08); border-color: #cbd5e1; }
  .card.dragging { opacity: 0.45; }
  .card-labels { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 6px; }
  .label-chip {
    font-size: 0.68rem; font-weight: 700; padding: 2px 7px; border-radius: 99px;
    background: #e0e7ff; color: #3730a3;
  }
  .card-title { font-weight: 600; font-size: 0.9rem; line-height: 1.35; margin-bottom: 7px; word-break: break-word; }
  .card-badges { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; font-size: 0.73rem; color: var(--muted); }
  .badge-overdue { background: #fee2e2; color: var(--danger); font-weight: 700; padding: 2px 6px; border-radius: 4px; }
  .badge-blocked { background: #fef3c7; color: var(--amber); font-weight: 700; padding: 2px 6px; border-radius: 4px; }
  .badge-due { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; }
  .badge-user { margin-left: auto; background: #ecfdf5; color: var(--emerald); font-weight: 600; padding: 2px 7px; border-radius: 99px; }

  /* MODALS */
  .backdrop {
    position: fixed; inset: 0; background: rgba(15,23,42,0.55); z-index: 80;
    display: none; align-items: center; justify-content: center; padding: 18px;
  }
  .backdrop.open { display: flex; }
  .modal {
    background: #fff; border-radius: 12px; width: 100%; max-width: 680px; max-height: 90vh;
    overflow-y: auto; padding: 22px 24px; box-shadow: 0 24px 60px rgba(15,23,42,0.3);
    display: flex; flex-direction: column; gap: 14px;
  }
  .modal-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; border-bottom: 1px solid var(--border); padding-bottom: 10px; }
  .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
  .field label { display: block; font-size: 0.75rem; font-weight: 700; color: var(--muted); text-transform: uppercase; margin-bottom: 4px; }
  .checklist-box, .history-box { background: #f8fafc; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
  .cl-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 0.86rem; }
  .hist-item { font-size: 0.78rem; color: var(--muted); padding: 4px 0; border-bottom: 1px solid #eef2f7; }
  .hist-item strong { color: var(--text); }
  @media (max-width: 780px) {
    .workspace { flex-direction: column; }
    .sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); }
    .grid-2 { grid-template-columns: 1fr; }
  }
</style>
</head>
<body>
  <header class="topbar">
    <div class="brand">
      <span class="brand-icon">K</span>
      <span>KanvaFlow</span>
    </div>
    <form class="search-wrap" onsubmit="runSearch(event)">
      <input id="searchInput" placeholder="Search cards by meaning (Dodil Models AI)..." aria-label="Semantic search">
      <button class="btn btn-dark" type="submit">Search</button>
    </form>
    <div class="top-right">
      <button class="btn btn-dark" onclick="openAgenda()">My Week</button>
      <span class="user-pill" id="mePill">...</span>
      <a class="btn btn-dark" href="/.dodil/auth/logout" style="text-decoration:none;">Sign out</a>
    </div>
  </header>

  <div class="workspace">
    <aside class="sidebar">
      <div class="side-title">Your Boards</div>
      <ul class="board-nav" id="boardNav"></ul>
      <form class="new-board-box" onsubmit="createBoard(event)">
        <div class="side-title">Create Board</div>
        <input class="input" id="newBoardTitle" placeholder="e.g. Q4 Product Launch" required>
        <input class="input" id="newBoardDesc" placeholder="Short description (optional)">
        <button class="btn btn-primary" type="submit" style="justify-content:center;">+ New Board</button>
      </form>
      <div style="margin-top:auto; font-size:0.75rem; color:var(--muted); border-top:1px solid var(--border); padding-top:10px;">
        <strong>Claude / ChatGPT MCP:</strong><br>
        Connect to <code id="mcpUrl">/mcp</code>
      </div>
    </aside>

    <main class="stage">
      <div id="infoBanner" class="banner">
        <div id="infoBannerText"></div>
        <button class="btn btn-ghost btn-sm" onclick="document.getElementById('infoBanner').style.display='none'">Close</button>
      </div>
      <div id="boardContainer"></div>
    </main>
  </div>

  <!-- CARD DETAIL MODAL -->
  <div class="backdrop" id="cardModal">
    <div class="modal" id="cardModalBody"></div>
  </div>

  <!-- TEAM / ACTIVITY / AGENDA MODAL -->
  <div class="backdrop" id="auxModal">
    <div class="modal" id="auxModalBody"></div>
  </div>

<script>
let STATE = { me: '', boards: [], current: null, overdueOnly: false, dragCardId: null };
document.getElementById('mcpUrl').textContent = location.origin + '/mcp';

async function api(url, opts = {}) {
  const res = await fetch(url, {
    headers: { 'content-type': 'application/json', ...(opts.headers || {}) },
    ...opts,
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(data.detail || ('HTTP ' + res.status));
  return data;
}

function esc(s) {
  return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}

function showBanner(html) {
  const b = document.getElementById('infoBanner');
  document.getElementById('infoBannerText').innerHTML = html;
  b.style.display = 'flex';
}

async function boot() {
  try {
    const me = await api('/api/me');
    STATE.me = me.email;
    document.getElementById('mePill').textContent = me.email;
    await refreshBoards();
  } catch (err) {
    showBanner('<strong>Error:</strong> ' + esc(err.message));
  }
}

async function refreshBoards(selectId = null) {
  STATE.boards = await api('/api/boards');
  const nav = document.getElementById('boardNav');
  if (!STATE.boards.length) {
    nav.innerHTML = '<li style="font-size:0.83rem;color:#64748b;">No boards yet — create your first board below.</li>';
    document.getElementById('boardContainer').innerHTML =
      '<div class="board-header"><div><h2>Welcome to KanvaFlow</h2><p style="color:#64748b;font-size:0.9rem;margin-top:4px;">Create a board in the left sidebar or ask Claude/ChatGPT over MCP to create one for you.</p></div></div>';
    return;
  }
  const targetId = selectId || (STATE.current && STATE.boards.some(b => b.id === STATE.current.id) ? STATE.current.id : STATE.boards[0].id);
  await loadBoard(targetId);
}

function renderSidebar() {
  const nav = document.getElementById('boardNav');
  nav.innerHTML = STATE.boards.map(b => `
    <li>
      <button class="${STATE.current && STATE.current.id === b.id ? 'active' : ''}" onclick="loadBoard('${esc(b.id)}')">
        <span>${esc(b.title)}${b.overdue_count ? ' <span style="color:#dc2626;">●</span>' : ''}</span>
        <span class="role-tag">${esc(b.role)}</span>
      </button>
    </li>
  `).join('');
}

async function createBoard(e) {
  e.preventDefault();
  const titleEl = document.getElementById('newBoardTitle');
  const descEl = document.getElementById('newBoardDesc');
  try {
    const created = await api('/api/boards', {
      method: 'POST',
      body: JSON.stringify({ title: titleEl.value, description: descEl.value })
    });
    titleEl.value = ''; descEl.value = '';
    await refreshBoards(created.id);
  } catch (err) { alert(err.message); }
}

async function loadBoard(id) {
  STATE.current = await api('/api/boards/' + encodeURIComponent(id));
  renderSidebar();
  renderBoard();
}

function canEdit() {
  return STATE.current && (STATE.current.role === 'owner' || STATE.current.role === 'member');
}

function renderBoard() {
  const b = STATE.current;
  if (!b) return;
  const editable = canEdit();
  const overdueTotal = b.cards.filter(c => c.overdue).length;

  const colsHtml = b.lists.map(lst => {
    let cards = b.cards.filter(c => c.list_name === lst.name);
    if (STATE.overdueOnly) cards = cards.filter(c => c.overdue);
    const cardsHtml = cards.map(c => {
      const doneSteps = (c.checklist || []).filter(i => i.done).length;
      const totalSteps = (c.checklist || []).length;
      return `
        <div class="card" ${editable ? 'draggable="true"' : ''}
             ondragstart="onDragStart(event, '${esc(c.id)}')"
             onclick="openCard('${esc(c.id)}')">
          ${c.labels && c.labels.length ? `<div class="card-labels">${c.labels.map(l => `<span class="label-chip">${esc(l)}</span>`).join('')}</div>` : ''}
          <div class="card-title">${esc(c.title)}</div>
          <div class="card-badges">
            <span style="font-family:monospace;">#${esc(c.id.slice(0,4))}</span>
            ${c.overdue ? `<span class="badge-overdue">OVERDUE ${esc(c.due_date)}</span>` : (c.due_date ? `<span class="badge-due">Due ${esc(c.due_date)}</span>` : '')}
            ${c.is_blocked ? `<span class="badge-blocked">BLOCKED (${c.unresolved_blockers.length})</span>` : ''}
            ${totalSteps ? `<span class="badge-due">✓ ${doneSteps}/${totalSteps}</span>` : ''}
            ${c.assignee ? `<span class="badge-user">${esc(c.assignee.split('@')[0])}</span>` : ''}
          </div>
        </div>
      `;
    }).join('');

    return `
      <section class="column"
               ondragover="onDragOver(event)"
               ondragleave="onDragLeave(event)"
               ondrop="onDrop(event, '${esc(lst.name)}')">
        <div class="col-head">
          <span>${esc(lst.name)} ${lst.is_done ? '✓' : ''}</span>
          <span class="col-count">${cards.length}</span>
        </div>
        <div class="card-list">${cardsHtml}</div>
        ${editable ? `
          <form onsubmit="quickAddCard(event, '${esc(lst.name)}')" style="display:flex;gap:6px;margin-top:4px;">
            <input class="input" name="title" placeholder="+ Add card..." required style="padding:6px 9px;font-size:0.82rem;">
            <button class="btn btn-primary btn-sm" type="submit">Add</button>
          </form>
        ` : ''}
      </section>
    `;
  }).join('');

  document.getElementById('boardContainer').innerHTML = `
    <div class="board-header">
      <div class="board-title-wrap">
        <h1>${esc(b.title)} <span class="role-tag">${esc(b.role)}</span></h1>
        ${b.description ? `<p style="font-size:0.85rem;color:#64748b;margin-top:2px;">${esc(b.description)}</p>` : ''}
      </div>
      <div class="board-actions">
        <button class="btn ${STATE.overdueOnly ? 'btn-danger' : 'btn-ghost'}" onclick="toggleOverdue()">
          Overdue (${overdueTotal})
        </button>
        <button class="btn btn-ghost" onclick="runBoardSummary()">✨ AI Daily Summary</button>
        <button class="btn btn-ghost" onclick="openTeamModal()">Team (${b.members.length})</button>
        <button class="btn btn-ghost" onclick="openActivityModal()">Activity</button>
        ${editable ? `<button class="btn btn-primary" onclick="promptNewList()">+ Add List</button>` : ''}
      </div>
    </div>
    <div class="columns">${colsHtml}</div>
  `;
}

function toggleOverdue() { STATE.overdueOnly = !STATE.overdueOnly; renderBoard(); }

function onDragStart(e, cardId) {
  STATE.dragCardId = cardId;
  e.dataTransfer.effectAllowed = 'move';
  e.currentTarget.classList.add('dragging');
}
function onDragOver(e) {
  if (!canEdit()) return;
  e.preventDefault();
  e.currentTarget.classList.add('drag-over');
}
function onDragLeave(e) { e.currentTarget.classList.remove('drag-over'); }
async function onDrop(e, listName) {
  e.preventDefault();
  e.currentTarget.classList.remove('drag-over');
  if (!canEdit() || !STATE.dragCardId) return;
  const cid = STATE.dragCardId;
  STATE.dragCardId = null;
  try {
    await api('/api/cards/' + encodeURIComponent(cid) + '/move', {
      method: 'POST',
      body: JSON.stringify({ list_name: listName })
    });
    await loadBoard(STATE.current.id);
    await refreshBoards(STATE.current.id);
  } catch (err) { alert(err.message); }
}

async function quickAddCard(e, listName) {
  e.preventDefault();
  const title = e.target.title.value.trim();
  if (!title) return;
  e.target.title.value = '';
  try {
    await api(`/api/boards/${encodeURIComponent(STATE.current.id)}/cards`, {
      method: 'POST',
      body: JSON.stringify({ title, list_name: listName })
    });
    await refreshBoards(STATE.current.id);
  } catch (err) { alert(err.message); }
}

async function promptNewList() {
  const name = prompt('New list column name (e.g. Review, Blocked, QA):');
  if (!name || !name.trim()) return;
  const isDone = confirm('Treat cards in "' + name.trim() + '" as completed (Done)? Click OK for Yes, Cancel for No.');
  try {
    await api(`/api/boards/${encodeURIComponent(STATE.current.id)}/lists`, {
      method: 'POST',
      body: JSON.stringify({ name: name.trim(), is_done: isDone })
    });
    await loadBoard(STATE.current.id);
  } catch (err) { alert(err.message); }
}

async function runBoardSummary() {
  if (!STATE.current) return;
  showBanner('<em>Generating daily standup summary via Dodil Models (kimi-k2.6)...</em>');
  try {
    const res = await api(`/api/boards/${encodeURIComponent(STATE.current.id)}/summary`, { method: 'POST' });
    showBanner(`<div><strong>✨ AI Daily Summary — ${esc(res.board_title)}:</strong><br>${esc(res.summary)}</div>`);
  } catch (err) {
    showBanner('<strong>AI Summary Error:</strong> ' + esc(err.message));
  }
}

async function runSearch(e) {
  e.preventDefault();
  const q = document.getElementById('searchInput').value.trim();
  if (!q) return;
  try {
    const items = await api('/api/search?q=' + encodeURIComponent(q));
    if (!items.length) {
      showBanner(`<strong>Search "${esc(q)}":</strong> No matching cards found across your boards.`);
      return;
    }
    const rows = items.map(c =>
      `<a href="#" onclick="jumpToCard('${esc(c.board_id)}','${esc(c.id)}');return false;" style="color:#4338ca;font-weight:600;">${esc(c.title)}</a> <small>(${esc(c.board_title)} • ${esc(c.list_name)} • score ${c.similarity})</small>`
    ).join('<br>');
    showBanner(`<div><strong>Semantic Search Results for "${esc(q)}":</strong><br>${rows}</div>`);
  } catch (err) { alert(err.message); }
}

async function jumpToCard(boardId, cardId) {
  await loadBoard(boardId);
  await openCard(cardId);
}

async function openCard(cardId) {
  const b = STATE.current;
  const c = b.cards.find(x => x.id === cardId);
  if (!c) return;
  const editable = canEdit();
  const hist = await api(`/api/cards/${encodeURIComponent(cardId)}/history`);
  const otherCards = b.cards.filter(x => x.id !== c.id && !(c.blocked_by || []).includes(x.id));

  const blockersHtml = (c.blocked_by || []).map(bid => {
    const bc = b.cards.find(x => x.id === bid);
    const label = bc ? `${bc.title} (${bc.list_name})` : bid;
    return `<div style="display:flex;justify-content:space-between;align-items:center;padding:4px 0;font-size:0.84rem;">
      <span>⛔ ${esc(label)}</span>
      ${editable ? `<button class="btn btn-danger btn-sm" onclick="removeBlocker('${esc(c.id)}','${esc(bid)}')">Unlink</button>` : ''}
    </div>`;
  }).join('') || '<div style="font-size:0.82rem;color:#64748b;">No blockers linked.</div>';

  const checklistHtml = (c.checklist || []).map((item, idx) => `
    <label class="cl-row">
      <input type="checkbox" ${item.done ? 'checked' : ''} ${editable ? '' : 'disabled'} onchange="toggleCheck('${esc(c.id)}', ${idx}, this.checked)">
      <span style="${item.done ? 'text-decoration:line-through;color:#64748b;' : ''}">${esc(item.text)}</span>
    </label>
  `).join('') || '<div style="font-size:0.82rem;color:#64748b;">No checklist steps yet.</div>';

  document.getElementById('cardModalBody').innerHTML = `
    <div class="modal-head">
      <div>
        <span style="font-family:monospace;font-size:0.78rem;color:#64748b;">Card #${esc(c.id)}</span>
        <h3 style="font-size:1.15rem;">${esc(c.title)}</h3>
      </div>
      <button class="btn btn-ghost btn-sm" onclick="closeModal('cardModal')">✕</button>
    </div>

    <form onsubmit="saveCardDetails(event, '${esc(c.id)}')" style="display:flex;flex-direction:column;gap:10px;">
      <div class="field">
        <label>Title</label>
        <input class="input" name="title" value="${esc(c.title)}" ${editable ? '' : 'disabled'} required>
      </div>
      <div class="field">
        <label>Description</label>
        <textarea class="input" name="description" rows="3" ${editable ? '' : 'disabled'} placeholder="Notes, acceptance criteria...">${esc(c.description)}</textarea>
      </div>
      <div class="grid-2">
        <div class="field">
          <label>List</label>
          <select class="input" name="list_name" ${editable ? '' : 'disabled'}>
            ${b.lists.map(l => `<option value="${esc(l.name)}" ${l.name === c.list_name ? 'selected' : ''}>${esc(l.name)}</option>`).join('')}
          </select>
        </div>
        <div class="field">
          <label>Assignee (Email)</label>
          <input class="input" name="assignee" list="memberEmails" value="${esc(c.assignee || '')}" ${editable ? '' : 'disabled'} placeholder="member@org.io">
          <datalist id="memberEmails">${b.members.map(m => `<option value="${esc(m.email)}">`).join('')}</datalist>
        </div>
        <div class="field">
          <label>Due Date</label>
          <input class="input" type="date" name="due_date" value="${esc(c.due_date || '')}" ${editable ? '' : 'disabled'}>
        </div>
        <div class="field">
          <label>Labels (comma-separated)</label>
          <input class="input" name="labels" value="${esc((c.labels || []).join(', '))}" ${editable ? '' : 'disabled'} placeholder="ops, urgent, launch">
        </div>
      </div>
      ${editable ? `<div style="display:flex;justify-content:space-between;gap:8px;">
        <button class="btn btn-primary" type="submit">Save Changes</button>
        <button class="btn btn-danger" type="button" onclick="deleteCard('${esc(c.id)}')">Delete Card</button>
      </div>` : ''}
    </form>

    <div class="checklist-box">
      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
        <strong style="font-size:0.84rem;">Checklist</strong>
        ${editable ? `<button class="btn btn-ghost btn-sm" onclick="aiChecklist('${esc(c.id)}')">✨ AI Generate Steps</button>` : ''}
      </div>
      ${checklistHtml}
      ${editable ? `
        <form onsubmit="addCheckItem(event, '${esc(c.id)}')" style="display:flex;gap:6px;margin-top:8px;">
          <input class="input" name="step" placeholder="Add step..." style="padding:5px 8px;font-size:0.82rem;" required>
          <button class="btn btn-ghost btn-sm" type="submit">Add</button>
        </form>
      ` : ''}
    </div>

    <div class="checklist-box">
      <strong style="font-size:0.84rem;display:block;margin-bottom:6px;">Dependencies (Blocked By)</strong>
      ${blockersHtml}
      ${editable && otherCards.length ? `
        <form onsubmit="addBlocker(event, '${esc(c.id)}')" style="display:flex;gap:6px;margin-top:8px;">
          <select class="input" name="blocked_by_id" style="padding:5px 8px;font-size:0.82rem;">
            ${otherCards.map(o => `<option value="${esc(o.id)}">${esc(o.title)} (${esc(o.list_name)})</option>`).join('')}
          </select>
          <button class="btn btn-ghost btn-sm" type="submit">+ Link Blocker</button>
        </form>
      ` : ''}
    </div>

    <div class="history-box">
      <strong style="font-size:0.84rem;display:block;margin-bottom:6px;">Card Audit History</strong>
      ${hist.map(h => `<div class="hist-item"><strong>${esc(h.actor)}</strong> ${esc(h.action)} <span style="float:right;">${esc((h.event_at || '').slice(0,19).replace('T',' '))}</span></div>`).join('') || '<div style="font-size:0.8rem;color:#64748b;">No history yet.</div>'}
    </div>
  `;
  document.getElementById('cardModal').classList.add('open');
}

function closeModal(id) { document.getElementById(id).classList.remove('open'); }

async function saveCardDetails(e, cardId) {
  e.preventDefault();
  const f = e.target;
  const labels = f.labels.value.split(',').map(s => s.trim()).filter(Boolean);
  try {
    await api('/api/cards/' + encodeURIComponent(cardId), {
      method: 'PATCH',
      body: JSON.stringify({
        title: f.title.value,
        description: f.description.value,
        assignee: f.assignee.value,
        due_date: f.due_date.value,
        labels
      })
    });
    if (f.list_name.value !== STATE.current.cards.find(x => x.id === cardId).list_name) {
      await api('/api/cards/' + encodeURIComponent(cardId) + '/move', {
        method: 'POST',
        body: JSON.stringify({ list_name: f.list_name.value })
      });
    }
    closeModal('cardModal');
    await refreshBoards(STATE.current.id);
  } catch (err) { alert(err.message); }
}

async function toggleCheck(cardId, idx, checked) {
  const c = STATE.current.cards.find(x => x.id === cardId);
  const next = (c.checklist || []).map((it, i) => i === idx ? { ...it, done: checked } : it);
  await api('/api/cards/' + encodeURIComponent(cardId), { method: 'PATCH', body: JSON.stringify({ checklist: next }) });
  await loadBoard(STATE.current.id);
  await openCard(cardId);
}

async function addCheckItem(e, cardId) {
  e.preventDefault();
  const c = STATE.current.cards.find(x => x.id === cardId);
  const next = [...(c.checklist || []), { text: e.target.step.value.trim(), done: false }];
  await api('/api/cards/' + encodeURIComponent(cardId), { method: 'PATCH', body: JSON.stringify({ checklist: next }) });
  await loadBoard(STATE.current.id);
  await openCard(cardId);
}

async function aiChecklist(cardId) {
  try {
    await api(`/api/cards/${encodeURIComponent(cardId)}/checklist`, {
      method: 'POST',
      body: JSON.stringify({ apply: true })
    });
    await loadBoard(STATE.current.id);
    await openCard(cardId);
  } catch (err) { alert('AI Checklist error: ' + err.message); }
}

async function addBlocker(e, cardId) {
  e.preventDefault();
  try {
    await api(`/api/cards/${encodeURIComponent(cardId)}/blockers`, {
      method: 'POST',
      body: JSON.stringify({ blocked_by_id: e.target.blocked_by_id.value })
    });
    await loadBoard(STATE.current.id);
    await openCard(cardId);
  } catch (err) { alert(err.message); }
}

async function removeBlocker(cardId, blockerId) {
  try {
    await api(`/api/cards/${encodeURIComponent(cardId)}/blockers/${encodeURIComponent(blockerId)}`, { method: 'DELETE' });
    await loadBoard(STATE.current.id);
    await openCard(cardId);
  } catch (err) { alert(err.message); }
}

async function deleteCard(cardId) {
  if (!confirm('Delete this card permanently?')) return;
  await api('/api/cards/' + encodeURIComponent(cardId), { method: 'DELETE' });
  closeModal('cardModal');
  await refreshBoards(STATE.current.id);
}

function openTeamModal() {
  const b = STATE.current;
  const isOwner = b.role === 'owner';
  document.getElementById('auxModalBody').innerHTML = `
    <div class="modal-head">
      <h3>Team &amp; Permissions — ${esc(b.title)}</h3>
      <button class="btn btn-ghost btn-sm" onclick="closeModal('auxModal')">✕</button>
    </div>
    <div>
      ${b.members.map(m => `
        <div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid #e2e8f0;font-size:0.88rem;">
          <span><strong>${esc(m.email)}</strong></span>
          <span style="display:flex;gap:8px;align-items:center;">
            <span class="role-tag">${esc(m.role)}</span>
            ${isOwner && m.role !== 'owner' ? `<button class="btn btn-danger btn-sm" onclick="removeMember('${esc(m.email)}')">Remove</button>` : ''}
          </span>
        </div>
      `).join('')}
    </div>
    ${isOwner ? `
      <form onsubmit="inviteMember(event)" style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap;">
        <input class="input" name="email" type="email" placeholder="teammate@company.com" required style="flex:1;">
        <select class="input" name="role" style="width:130px;">
          <option value="member">member (edit)</option>
          <option value="viewer">viewer (read)</option>
        </select>
        <button class="btn btn-primary" type="submit">Invite</button>
      </form>
    ` : '<p style="font-size:0.82rem;color:#64748b;">Only the board owner can invite or remove members.</p>'}
  `;
  document.getElementById('auxModal').classList.add('open');
}

async function inviteMember(e) {
  e.preventDefault();
  try {
    await api(`/api/boards/${encodeURIComponent(STATE.current.id)}/members`, {
      method: 'POST',
      body: JSON.stringify({ email: e.target.email.value, role: e.target.role.value })
    });
    await loadBoard(STATE.current.id);
    openTeamModal();
  } catch (err) { alert(err.message); }
}

async function removeMember(email) {
  try {
    await api(`/api/boards/${encodeURIComponent(STATE.current.id)}/members/${encodeURIComponent(email)}`, { method: 'DELETE' });
    await loadBoard(STATE.current.id);
    openTeamModal();
  } catch (err) { alert(err.message); }
}

async function openActivityModal() {
  const items = await api(`/api/boards/${encodeURIComponent(STATE.current.id)}/activity?limit=50`);
  document.getElementById('auxModalBody').innerHTML = `
    <div class="modal-head">
      <h3>Board Activity Log — ${esc(STATE.current.title)}</h3>
      <button class="btn btn-ghost btn-sm" onclick="closeModal('auxModal')">✕</button>
    </div>
    <div class="history-box">
      ${items.map(h => `<div class="hist-item"><strong>${esc(h.actor)}</strong> ${esc(h.action)} <span style="float:right;">${esc((h.event_at || '').slice(0,19).replace('T',' '))}</span></div>`).join('') || '<div>No activity recorded yet.</div>'}
    </div>
  `;
  document.getElementById('auxModal').classList.add('open');
}

async function openAgenda() {
  const ag = await api('/api/agenda?days=7');
  const sec = (label, arr) => `<div style="margin-bottom:10px;"><strong style="font-size:0.84rem;">${label} (${arr.length})</strong>` +
    (arr.length ? arr.map(c => `<div style="font-size:0.84rem;padding:4px 0;border-bottom:1px solid #f1f5f9;">
      <a href="#" onclick="closeModal('auxModal');jumpToCard('${esc(c.board_id)}','${esc(c.id)}');return false;" style="color:#4338ca;font-weight:600;">${esc(c.title)}</a>
      <span style="color:#64748b;"> — ${esc(c.board_title)} (${esc(c.list_name)}${c.due_date ? ', due ' + esc(c.due_date) : ''})</span>
    </div>`).join('') : '<div style="font-size:0.8rem;color:#94a3b8;">None</div>') + '</div>';

  document.getElementById('auxModalBody').innerHTML = `
    <div class="modal-head">
      <h3>My Plate This Week (${esc(ag.person)})</h3>
      <button class="btn btn-ghost btn-sm" onclick="closeModal('auxModal')">✕</button>
    </div>
    ${sec('🔴 Overdue', ag.overdue)}
    ${sec('📅 Due Within 7 Days', ag.due_this_week)}
    ${sec('⚡ In Progress (Doing)', ag.doing)}
    ${sec('📋 Other Assigned Cards', ag.backlog_assigned)}
  `;
  document.getElementById('auxModal').classList.add('open');
}

boot();
</script>
</body>
</html>
"""