closes board-viewer-card-meta-fix Review found two acceptance fields missing from cards: - project_owner was in JSON but never displayed - last_commit_iso was only consumed for relative age; no absolute date Now both rendered (server-side src/render.ts + client-side static/board.js in sync), with 4 new TDD tests: - renders owner pill when project_owner is set - omits owner pill when empty - renders short YYYY-MM-DD date when last_commit_iso is set - omits date span when null CSS: .badge.owner = transparent + border (visually distinct from solid .badge.project), .date = small muted tabular-nums. Smoke-verified in browser. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
201 lines
6.9 KiB
JavaScript
201 lines
6.9 KiB
JavaScript
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, '"')
|
|
.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'
|
|
? `<span class="badge status">${STATUS_EMOJI[rec.status]} ${STATUS_LABEL[rec.status]}</span>`
|
|
: `<span class="badge project">${escapeHtml(rec.project)}</span>`;
|
|
const ownerPill = rec.project_owner
|
|
? `<span class="badge owner">${escapeHtml(rec.project_owner)}</span>`
|
|
: '';
|
|
const dateSpan = rec.last_commit_iso
|
|
? `<span class="date" title="${escapeHtml(rec.last_commit_iso)}">${escapeHtml(rec.last_commit_iso.slice(0, 10))}</span>`
|
|
: '';
|
|
return `<li class="card" data-slug="${escapeHtml(rec.slug)}" data-raw-url="${escapeHtml(rec.raw_url)}" data-archived="${archived ? 'true' : 'false'}">
|
|
<div class="card-title">${escapeHtml(truncate(rec.title, 80))}</div>
|
|
<div class="card-meta">
|
|
${primary}
|
|
${ownerPill}
|
|
${dateSpan}
|
|
${age === null ? '' : `<span class="age">${escapeHtml(age)}</span>`}
|
|
</div>
|
|
</li>`;
|
|
}
|
|
|
|
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 `<section class="col" data-status="${status}">
|
|
<h2>${STATUS_EMOJI[status]} ${STATUS_LABEL[status]} <span class="count">${inCol.length}</span></h2>
|
|
<ul class="cards">${cards}</ul>
|
|
</section>`;
|
|
}).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 `<section class="col" data-project="${escapeHtml(project)}">
|
|
<h2>${escapeHtml(project)} <span class="count">${inCol.length}</span></h2>
|
|
<ul class="cards">${cards}</ul>
|
|
</section>`;
|
|
}).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);
|
|
|
|
function applyFilter(query) {
|
|
const q = query.trim().toLowerCase();
|
|
document.querySelectorAll('.card').forEach((card) => {
|
|
const slug = card.dataset.slug || '';
|
|
const project = card.querySelector('.badge.project')?.textContent || '';
|
|
const status = card.querySelector('.badge.status')?.textContent || '';
|
|
const title = card.querySelector('.card-title')?.textContent || '';
|
|
const hay = `${slug} ${project} ${status} ${title}`.toLowerCase();
|
|
card.dataset.hidden = q === '' || hay.includes(q) ? 'false' : 'true';
|
|
});
|
|
}
|
|
|
|
filterInput?.addEventListener('input', (e) => {
|
|
applyFilter(e.target.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 = `<h2>${escapeHtml(slug || '')}</h2><p>loading…</p>`;
|
|
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 = `<h2>${escapeHtml(slug || '')}</h2>` + marked.parse(md);
|
|
} catch (err) {
|
|
drawerContent.innerHTML = `<h2>${escapeHtml(slug || '')}</h2><p>Failed to load: ${escapeHtml(String(err))}</p>`;
|
|
}
|
|
}
|
|
|
|
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`);
|