feat(viewer): group-by-project toggle + Gitea 404 handling

- static/board.js: full rerender on toggle-grouping; by-project mode = N cols
  per project with status-badge inside cards, sorted by canonical status order
- static/board.css: drop placeholder, add .badge.status
- src/gitea.ts: getLatestCommitIso returns null on 404 (path never existed
  remotely; seen on books repo where some per-task slugs in STATUS.md
  don't have file in default branch) + new test
- .gitignore: dist/ already present; add .playwright-mcp/, board-*.png

Smoke-verified: dist/index.html, 78 records from 4 repos, both toggle modes
work in browser, drawer lazy-fetches markdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
vitya
2026-05-22 14:48:56 +03:00
parent 4c59241d15
commit c0661fb7b3
5 changed files with 129 additions and 25 deletions

View File

@@ -242,16 +242,6 @@ aside.drawer[hidden] {
padding: 0;
}
body.group-by-project main.board {
grid-template-columns: 1fr;
}
body.group-by-project main.board .col {
display: none;
}
body.group-by-project main.board::after {
content: "grouping by project: not implemented yet";
color: var(--muted);
padding: 1rem;
.badge.status {
background: var(--badge-bg);
}

View File

@@ -11,10 +11,116 @@ 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 meta = mode === 'by-project'
? `<span class="badge status">${STATUS_EMOJI[rec.status]} ${STATUS_LABEL[rec.status]}</span>`
: `<span class="badge project">${escapeHtml(rec.project)}</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">
${meta}
${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));
}
@@ -33,8 +139,9 @@ function applyFilter(query) {
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} ${title}`.toLowerCase();
const hay = `${slug} ${project} ${status} ${title}`.toLowerCase();
card.dataset.hidden = q === '' || hay.includes(q) ? 'false' : 'true';
});
}
@@ -47,8 +154,9 @@ toggleArchived?.addEventListener('change', (e) => {
document.body.classList.toggle('show-archived', e.target.checked);
});
toggleGrouping?.addEventListener('change', (e) => {
document.body.classList.toggle('group-by-project', e.target.checked);
toggleGrouping?.addEventListener('change', () => {
document.body.classList.toggle('group-by-project', toggleGrouping.checked);
rerender();
});
async function openDrawer(card) {
@@ -56,7 +164,7 @@ async function openDrawer(card) {
const rawUrl = card.dataset.rawUrl;
const slug = card.dataset.slug;
if (!rawUrl) return;
drawerContent.innerHTML = `<h2>${slug}</h2><p>loading…</p>`;
drawerContent.innerHTML = `<h2>${escapeHtml(slug || '')}</h2><p>loading…</p>`;
drawer.hidden = false;
try {
const res = await fetch(rawUrl, { credentials: 'omit' });
@@ -81,13 +189,4 @@ document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && drawer) drawer.hidden = true;
});
function escapeHtml(s) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
console.log(`board-viewer: ${records.length} records loaded`);