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; 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 renderCard(rec, now, mode) { 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 ? `${escapeHtml(rec.project_owner)}` : ''; const dateSpan = rec.last_commit_iso ? `${escapeHtml(rec.last_commit_iso.slice(0, 10))}` : ''; return `
  • ${escapeHtml(truncate(rec.title, 80))}
    ${primary} ${ownerPill} ${dateSpan} ${age === null ? '' : `${escapeHtml(age)}`}
  • `; } function renderByStatus(now) { return STATUS_ORDER.map((status) => { const inCol = records.filter((r) => r.status === status); const cards = inCol.map((r) => renderCard(r, now, 'by-status')).join(''); return `

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

    `; }).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')).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(); }); async function openDrawer(card) { if (!drawer || !drawerContent) return; const rawUrl = card.dataset.rawUrl; const slug = card.dataset.slug; if (!rawUrl) return; drawerContent.innerHTML = `

    ${escapeHtml(slug || '')}

    loading…

    `; drawer.hidden = false; try { const res = await fetch(rawUrl, { credentials: 'omit' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const md = await res.text(); drawerContent.innerHTML = `

    ${escapeHtml(slug || '')}

    ` + marked.parse(md); } catch (err) { drawerContent.innerHTML = `

    ${escapeHtml(slug || '')}

    Failed to load: ${escapeHtml(String(err))}

    `; } } document.addEventListener('click', (e) => { 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`);