chronis 0.5.3

Event-sourced task CLI powered by the AllSource embedded database (all-source.xyz)
Documentation
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>chronis — dashboard</title>
  <link rel="stylesheet" href="/style.css">
  <script src="/htmx.min.js"></script>
</head>
<body hx-headers='{"Accept": "text/html"}'>
  <div class="container">
    <header>
      <h1>chronis</h1>
      <nav>
        <a href="/" class="active">Dashboard</a>
        <a href="/kanban">Kanban</a>
        <a href="/graph">Graph</a>
      </nav>
      <div class="stats-bar"
           hx-get="/partials/stats"
           hx-trigger="load, every 2s, refresh from:body"
           hx-swap="innerHTML">
      </div>
    </header>

    <div class="toolbar">
      <div class="search-box">
        <input type="text" id="search-input" placeholder="Search tasks..."
               oninput="filterTasks()" autofocus>
      </div>
      <div class="filter-group">
        <button class="filter-btn active" data-filter="all" onclick="setFilter('all')">All</button>
        <button class="filter-btn" data-filter="open" onclick="setFilter('open')">Open</button>
        <button class="filter-btn" data-filter="in-progress" onclick="setFilter('in-progress')">In Progress</button>
        <button class="filter-btn" data-filter="done" onclick="setFilter('done')">Done</button>
      </div>
      <a href="/api/export" class="export-btn" download>Export .md</a>
    </div>

    <div class="layout">
      <div class="panel" style="overflow: auto; max-height: calc(100vh - 220px);">
        <table>
          <thead>
            <tr>
              <th>ID</th>
              <th>Type</th>
              <th>Title</th>
              <th>Pri</th>
              <th>Status</th>
              <th>Claimed</th>
              <th>Blocked</th>
              <th>Actions</th>
            </tr>
          </thead>
          <tbody id="task-list"
                 hx-get="/partials/task-list"
                 hx-trigger="load, every 2s, refresh from:body"
                 hx-swap="innerHTML">
          </tbody>
        </table>
      </div>

      <div class="panel" id="detail-pane">
        <p class="empty-state">Click a task to see details</p>
      </div>
    </div>

    <div class="shortcut-bar">
      <span><kbd class="kbd">/</kbd> search</span>
      <span><kbd class="kbd">1</kbd> open</span>
      <span><kbd class="kbd">2</kbd> in-progress</span>
      <span><kbd class="kbd">3</kbd> done</span>
      <span><kbd class="kbd">0</kbd> all</span>
      <span><kbd class="kbd">j</kbd><kbd class="kbd">k</kbd> navigate</span>
      <span><kbd class="kbd">Enter</kbd> detail</span>
      <span><kbd class="kbd">e</kbd> export</span>
    </div>
  </div>

  <div class="toast" id="toast"></div>

  <script>
    let currentFilter = 'all';
    let selectedRow = -1;

    function setFilter(filter) {
      currentFilter = filter;
      document.querySelectorAll('.filter-btn').forEach(btn => {
        btn.classList.toggle('active', btn.dataset.filter === filter);
      });
      filterTasks();
    }

    function filterTasks() {
      const query = document.getElementById('search-input').value.toLowerCase();
      const rows = document.querySelectorAll('#task-list .task-row');
      rows.forEach(row => {
        const text = row.textContent.toLowerCase();
        const status = row.dataset.status || '';
        const matchSearch = !query || text.includes(query);
        const matchFilter = currentFilter === 'all' || status === currentFilter;
        row.style.display = matchSearch && matchFilter ? '' : 'none';
      });
      selectedRow = -1;
    }

    function getVisibleRows() {
      return Array.from(document.querySelectorAll('#task-list .task-row')).filter(r => r.style.display !== 'none');
    }

    function selectRow(index) {
      const rows = getVisibleRows();
      rows.forEach(r => r.classList.remove('selected'));
      if (index >= 0 && index < rows.length) {
        selectedRow = index;
        rows[index].classList.add('selected');
        rows[index].scrollIntoView({ block: 'nearest' });
      }
    }

    function showToast(msg) {
      const t = document.getElementById('toast');
      t.textContent = msg;
      t.classList.add('show');
      setTimeout(() => t.classList.remove('show'), 2000);
    }

    document.addEventListener('keydown', function(e) {
      const searchInput = document.getElementById('search-input');
      const isSearchFocused = document.activeElement === searchInput;

      if (e.key === '/' && !isSearchFocused) {
        e.preventDefault();
        searchInput.focus();
        return;
      }

      if (e.key === 'Escape' && isSearchFocused) {
        searchInput.blur();
        searchInput.value = '';
        filterTasks();
        return;
      }

      if (isSearchFocused) return;

      switch (e.key) {
        case 'j': selectRow(selectedRow + 1); break;
        case 'k': selectRow(Math.max(0, selectedRow - 1)); break;
        case 'Enter': {
          const rows = getVisibleRows();
          if (selectedRow >= 0 && selectedRow < rows.length) rows[selectedRow].click();
          break;
        }
        case '1': setFilter('open'); break;
        case '2': setFilter('in-progress'); break;
        case '3': setFilter('done'); break;
        case '0': setFilter('all'); break;
        case 'e': window.location.href = '/api/export'; break;
      }
    });

    // Re-apply filter after HTMX swaps
    document.body.addEventListener('htmx:afterSwap', function(e) {
      if (e.detail.target.id === 'task-list') {
        filterTasks();
      }
    });
  </script>
</body>
</html>