import { marked } from 'https://esm.sh/marked@14'; const recordsEl = document.getElementById('records'); const records = recordsEl ? JSON.parse(recordsEl.textContent || '[]') : []; const filterInput = document.querySelector('.filter'); const toggleArchived = document.querySelector('.toggle-archived'); const toggleGrouping = document.querySelector('.toggle-grouping'); const drawer = document.querySelector('.drawer'); const drawerContent = drawer?.querySelector('.drawer-content'); const drawerClose = drawer?.querySelector('.drawer-close'); const refreshInfo = document.querySelector('.refresh-info'); const header = document.querySelector('header'); const board = document.querySelector('main.board'); const generatedAt = header?.dataset.generated ? new Date(header.dataset.generated) : null; const NEXT_TICK_MIN = 5; const STATUS_ORDER = ['open', 'in_progress', 'paused', 'blocked', 'done']; const STATUS_LABEL = { open: 'open', in_progress: 'in-progress', paused: 'paused', blocked: 'blocked', done: 'done', }; const STATUS_EMOJI = { open: '⚪', in_progress: '🔴', paused: '🟡', blocked: '🔵', done: '🟢', }; const ARCHIVE_DAYS = 14; const DONE_VISIBLE_LIMIT = 10; const SOLO_OWNER = (() => { const owners = new Set(); for (const r of records) if (r.project_owner) owners.add(r.project_owner); return owners.size === 1 ? [...owners][0] : null; })(); function escapeHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function formatAge(iso, now) { if (!iso) return null; const then = new Date(iso); const diff = Math.max(0, now.getTime() - then.getTime()); const m = Math.floor(diff / 60_000); if (m < 60) return `${m}m`; const h = Math.floor(diff / 3_600_000); if (h < 24) return `${h}h`; const d = Math.floor(diff / 86_400_000); if (d < 7) return `${d}d`; if (d < 30) return `${Math.floor(d / 7)}w`; let mo = (now.getUTCFullYear() - then.getUTCFullYear()) * 12 + (now.getUTCMonth() - then.getUTCMonth()); if (now.getUTCDate() < then.getUTCDate()) mo -= 1; mo = Math.max(0, mo); if (mo < 12) return `${mo}mo`; return `${Math.floor(mo / 12)}y`; } function isArchived(rec, now) { if (rec.status !== 'done' || !rec.last_commit_iso) return false; const ms = now.getTime() - new Date(rec.last_commit_iso).getTime(); return ms > ARCHIVE_DAYS * 86_400_000; } function truncate(s, max) { return s.length <= max ? s : s.slice(0, max) + '…'; } function formatMskDateTime(iso) { const utc = new Date(iso); const msk = new Date(utc.getTime() + 3 * 60 * 60 * 1000); const pad = (n) => String(n).padStart(2, '0'); return `${msk.getUTCFullYear()}-${pad(msk.getUTCMonth() + 1)}-${pad(msk.getUTCDate())} ${pad(msk.getUTCHours())}:${pad(msk.getUTCMinutes())} МСК`; } function renderCard(rec, now, mode, overflow) { const age = formatAge(rec.last_commit_iso, now); const archived = isArchived(rec, now); const primary = mode === 'by-project' ? `${STATUS_EMOJI[rec.status]} ${STATUS_LABEL[rec.status]}` : `${escapeHtml(rec.project)}`; const ownerPill = rec.project_owner && SOLO_OWNER === null ? `${escapeHtml(rec.project_owner)}` : ''; const dateSpan = rec.last_commit_iso ? `${escapeHtml(formatMskDateTime(rec.last_commit_iso))}` : ''; return `
  • ${escapeHtml(truncate(rec.title, 80))}
    ${escapeHtml(rec.slug)}
    ${primary} ${ownerPill} ${dateSpan} ${age === null ? '' : `${escapeHtml(age)}`}
  • `; } function compareByIsoDesc(a, b) { if (!a.last_commit_iso && !b.last_commit_iso) return 0; if (!a.last_commit_iso) return 1; if (!b.last_commit_iso) return -1; return b.last_commit_iso.localeCompare(a.last_commit_iso); } function renderByStatus(now) { return STATUS_ORDER.map((status) => { let inCol = records.filter((r) => r.status === status); let overflowStart = -1; if (status === 'done') { inCol = [...inCol].sort(compareByIsoDesc); if (inCol.length > DONE_VISIBLE_LIMIT) overflowStart = DONE_VISIBLE_LIMIT; } const cards = inCol .map((r, i) => renderCard(r, now, 'by-status', overflowStart !== -1 && i >= overflowStart)) .join(''); const overflowCount = overflowStart === -1 ? 0 : inCol.length - overflowStart; const expander = overflowCount > 0 ? `` : ''; return `

    ${STATUS_EMOJI[status]} ${STATUS_LABEL[status]} ${inCol.length}

    ${expander}
    `; }).join(''); } function renderByProject(now) { const projects = [...new Set(records.map((r) => r.project))].sort(); return projects.map((project) => { const inCol = records .filter((r) => r.project === project) .sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status)); const cards = inCol.map((r) => renderCard(r, now, 'by-project', false)).join(''); return `

    ${escapeHtml(project)} ${inCol.length}

    `; }).join(''); } function rerender() { if (!board) return; const now = new Date(); const byProject = toggleGrouping?.checked; board.innerHTML = byProject ? renderByProject(now) : renderByStatus(now); if (filterInput) applyFilter(filterInput.value); } function relativeMin(ms) { return Math.max(0, Math.floor(ms / 60000)); } function updateRefreshInfo() { if (!refreshInfo || !generatedAt) return; const ageMin = relativeMin(Date.now() - generatedAt.getTime()); const nextMin = Math.max(0, NEXT_TICK_MIN - ageMin); refreshInfo.textContent = `refreshed ${ageMin}m ago · next in ${nextMin}m`; } updateRefreshInfo(); setInterval(updateRefreshInfo, 30_000); const recordsBySlug = new Map(records.map((r) => [r.slug, r])); const activeStatuses = new Set(); function applyFilter(query) { const q = query.trim().toLowerCase(); document.querySelectorAll('.card').forEach((card) => { const slug = card.dataset.slug || ''; const rec = recordsBySlug.get(slug); if (!rec) { card.dataset.hidden = 'true'; return; } const textHay = [ rec.slug, rec.title, rec.project, rec.project_owner ?? '', rec.status, ] .join(' ') .toLowerCase(); const textMatch = q === '' || textHay.includes(q); const statusMatch = activeStatuses.size === 0 || activeStatuses.has(rec.status); card.dataset.hidden = textMatch && statusMatch ? 'false' : 'true'; }); } filterInput?.addEventListener('input', (e) => { applyFilter(e.target.value); }); document.querySelectorAll('.status-filter').forEach((btn) => { btn.addEventListener('click', () => { const s = btn.dataset.status; if (!s) return; if (activeStatuses.has(s)) { activeStatuses.delete(s); btn.classList.remove('active'); } else { activeStatuses.add(s); btn.classList.add('active'); } applyFilter(filterInput?.value || ''); }); }); toggleArchived?.addEventListener('change', (e) => { document.body.classList.toggle('show-archived', e.target.checked); }); toggleGrouping?.addEventListener('change', () => { document.body.classList.toggle('group-by-project', toggleGrouping.checked); rerender(); }); function openDrawer(card) { if (!drawer || !drawerContent) return; const slug = card.dataset.slug || ''; const rec = recordsBySlug.get(slug); drawer.hidden = false; if (!rec) { drawerContent.innerHTML = `

    ${escapeHtml(slug)}

    record not found.

    `; return; } const md = rec.md_content; if (md === null || md === undefined) { drawerContent.innerHTML = `

    ${escapeHtml(slug)}

    ` + `

    No per-task .md file in repo — status block only.

    ` + renderStatusBlock(rec); return; } drawerContent.innerHTML = `

    ${escapeHtml(slug)}

    ` + marked.parse(md); } function renderStatusBlock(rec) { const rows = [ ['Status', `${STATUS_EMOJI[rec.status]} ${STATUS_LABEL[rec.status]}`], ['Title', rec.title], ['Where stopped', rec.where_stopped], ['Next action', rec.next_action], ['Blocker', rec.blocker], ['Branch', rec.branch], ]; return ( '
    ' + rows .filter(([, v]) => v !== null && v !== undefined && v !== '') .map( ([k, v]) => `
    ${escapeHtml(k)}
    ${escapeHtml(String(v))}
    `, ) .join('') + '
    ' ); } document.addEventListener('click', (e) => { const expander = e.target.closest?.('.done-expander'); if (expander) { const col = expander.closest('.col'); if (!col) return; if (!expander.dataset.original) expander.dataset.original = expander.textContent; const expanded = col.classList.toggle('show-overflow'); expander.textContent = expanded ? 'collapse' : expander.dataset.original; return; } const card = e.target.closest?.('.card'); if (card) openDrawer(card); }); drawerClose?.addEventListener('click', () => { if (drawer) drawer.hidden = true; }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && drawer) drawer.hidden = true; }); console.log(`board-viewer: ${records.length} records loaded`);