diff --git a/.gitignore b/.gitignore index 7869f4f..4e853c5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,11 @@ dist/ build/ *.egg-info/ +# Playwright MCP scratch +.playwright-mcp/ +board-viewer-*.png +board-by-project.png + # Environment .env .env.local diff --git a/src/gitea.ts b/src/gitea.ts index cf5ae0d..8a1486c 100644 --- a/src/gitea.ts +++ b/src/gitea.ts @@ -45,6 +45,7 @@ export function createGiteaClient(opts: GiteaClientOptions): GiteaClient { ): Promise { 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; diff --git a/static/board.css b/static/board.css index 6c7e68e..59e9f28 100644 --- a/static/board.css +++ b/static/board.css @@ -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); } diff --git a/static/board.js b/static/board.js index 86196a4..5d84e12 100644 --- a/static/board.js +++ b/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, '''); +} + +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' + ? `${STATUS_EMOJI[rec.status]} ${STATUS_LABEL[rec.status]}` + : `${escapeHtml(rec.project)}`; + return `
  • +
    ${escapeHtml(truncate(rec.title, 80))}
    +
    + ${meta} + ${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)); } @@ -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 = `

    ${slug}

    loading…

    `; + drawerContent.innerHTML = `

    ${escapeHtml(slug || '')}

    loading…

    `; 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, '''); -} - console.log(`board-viewer: ${records.length} records loaded`); diff --git a/tests/gitea.test.ts b/tests/gitea.test.ts index 8793041..72c5497 100644 --- a/tests/gitea.test.ts +++ b/tests/gitea.test.ts @@ -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', () => {