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:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,6 +9,11 @@ dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Playwright MCP scratch
|
||||
.playwright-mcp/
|
||||
board-viewer-*.png
|
||||
board-by-project.png
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
@@ -45,6 +45,7 @@ export function createGiteaClient(opts: GiteaClientOptions): GiteaClient {
|
||||
): Promise<string | null> {
|
||||
const url = `${base}/api/v1/repos/${owner}/${repo}/commits?path=${encodeURIComponent(path)}&limit=1`;
|
||||
const res = await fetchImpl(url, { headers });
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`Gitea getLatestCommitIso ${owner}/${repo}/${path}: HTTP ${res.status}`);
|
||||
const body = (await res.json()) as CommitEntry[];
|
||||
return body[0]?.commit?.author?.date ?? null;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
125
static/board.js
125
static/board.js
@@ -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, '&')
|
||||
.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 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
console.log(`board-viewer: ${records.length} records loaded`);
|
||||
|
||||
@@ -100,6 +100,15 @@ describe('GiteaClient.getLatestCommitIso', () => {
|
||||
|
||||
expect(await client.getLatestCommitIso('o', 'r', 'p.md')).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when Gitea responds 404 (path never existed)', async () => {
|
||||
const mockFetch: MockFetch = vi.fn().mockResolvedValue(
|
||||
new Response('Not Found', { status: 404 }),
|
||||
);
|
||||
const client = makeClient(mockFetch);
|
||||
|
||||
expect(await client.getLatestCommitIso('o', 'r', 'missing.md')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GiteaClient.rawUrl', () => {
|
||||
|
||||
Reference in New Issue
Block a user