feat(polish): close all 6 review nitpicks in one sweep

closes board-viewer-polish

- M1 src/parser.ts: comment explaining why **Status:** text field is intentionally
  ignored (emoji is canonical, prevents future-reader head-scratch)
- M2 src/render.ts + tests: clamp future ISO to '0m' (server clock drift safety)
  + inline comment + test
- M3 .tasks/board-viewer-gitea-reader.md: decisions-log clarifying orphan
  per-task files are out of scope by design (STATUS.md = index of record)
- M4 static/board.{js,css} + src/render.ts: filter rebuilt to query records
  (not DOM badge text); 5 status-filter pill buttons in header with active
  toggle state; smoke-verified blocked filter
- M5 deploy/Dockerfile.build: HEALTHCHECK (file fresher than 2×tick) and
  stderr redirect for failures with timestamp
- M6 src/reader.ts: parallelize getLatestCommitIso per-repo via Promise.all
  (preserves block order)

49 tests pass (was 43 pre-polish), typecheck clean, smoke against real Gitea
returns 81 records from 4 repos.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
vitya
2026-05-22 15:46:47 +03:00
parent 930e404545
commit 469ff112cc
10 changed files with 126 additions and 20 deletions

View File

@@ -57,10 +57,10 @@ _Updated: 2026-05-22_
--- ---
## [board-viewer-polish] — bundle of 6 minor/nitpick findings from review ## 🟢 [board-viewer-polish] — bundle of 6 minor/nitpick findings from review
**Status:** ready **Status:** done
**Where I stopped:** detailed list в task-файле; парсер comment, formatAge future-ts test, gitea-reader decisions-log clarification, filter status multi-select, docker healthcheck+stderr, parallel commit-iso. **Where I stopped:** все 6 items закрыты в одной сессии. 49 тестов pass (+2 от polish: M2 future-ts, M4 status-filter markup). Smoke-verified: status pill click фильтрует cards.
**Next action:** забирать items по одному. См. `board-viewer-polish.md`. **Next action:**
**Branch:** master **Branch:** master
--- ---

View File

@@ -47,6 +47,7 @@
- 2026-05-22: task создан промоушеном; контракт фиксирован — это публичный API для html-render. - 2026-05-22: task создан промоушеном; контракт фиксирован — это публичный API для html-render.
- 2026-05-22: emoji-словарь выравнен под реальные `.tasks/STATUS.md` (using-tasks skill): ⚪ open / 🔴 in_progress / 🟡 paused / 🔵 blocked / 🟢 done. Раньше дизайн-док и acceptance путали 🔴/🔵 — исправлено. - 2026-05-22: emoji-словарь выравнен под реальные `.tasks/STATUS.md` (using-tasks skill): ⚪ open / 🔴 in_progress / 🟡 paused / 🔵 blocked / 🟢 done. Раньше дизайн-док и acceptance путали 🔴/🔵 — исправлено.
- 2026-05-22: язык — TypeScript / Node (vitest, ESM, Node 22). Repo discovery — whitelist `board_viewer_repos` в auth.toml. Без кэша Gitea API на MVP. - 2026-05-22: язык — TypeScript / Node (vitest, ESM, Node 22). Repo discovery — whitelist `board_viewer_repos` в auth.toml. Без кэша Gitea API на MVP.
- 2026-05-22 (post-review): "per-task без записи в STATUS.md (legacy)" из acceptance criteria — intentionally not supported. STATUS.md = index of record per design (`.wiki/concepts/board-viewer.md` строка 44: "SoT не меняется: `.tasks/<slug>.md` + `STATUS.md`"). Orphan per-task `.md` файлы без блока в STATUS.md — out of scope reader'а. Если потребуется — отдельная таска `reader-list-orphan-tasks`.
## Open questions ## Open questions

View File

@@ -48,12 +48,12 @@ Bundle of minor / nitpick findings from `board-viewer-review` checkpoint. Each
## Completed steps ## Completed steps
- [ ] M1: parser comment - [x] M1: comment в `parser.ts` объясняющий что `**Status:**` text field intentionally ignored (emoji canonical)
- [ ] M2: formatAge future-ts test + comment - [x] M2: тест clamps future timestamps to "0m" + inline comment в formatAge
- [ ] M3: decisions-log entry in gitea-reader.md - [x] M3: decisions-log entry в gitea-reader.md: orphan per-task без блока в STATUS.md — out of scope by design
- [ ] M4: filter rebuild + status multi-select OR acceptance amendment - [x] M4: filter rebuilt to query underlying records (status always known); 5 status-filter pill buttons добавлены в header; click toggles active set; CSS `.status-filter.active` filled. Smoke-verified: click "blocked" → только 1 blocked задача видна.
- [ ] M5: stderr + Docker HEALTHCHECK - [x] M5: HEALTHCHECK в Dockerfile.build (index.html exists AND fresher than 2×tick); failures redirected to stderr с timestamp
- [ ] M6: parallelize commit-iso fetches - [x] M6: `getLatestCommitIso` parallelized via Promise.all per-repo (preserves block order)
## Notes ## Notes

View File

@@ -10,5 +10,16 @@ COPY src ./src
COPY static ./static COPY static ./static
ENV BOARD_DIST_DIR=/output ENV BOARD_DIST_DIR=/output
ENV BOARD_TICK_SEC=300
ENTRYPOINT ["/bin/sh", "-c", "while true; do node --experimental-strip-types src/cli.ts \"$BOARD_CONFIG_PATH\" || echo 'render failed; will retry next tick'; sleep ${BOARD_TICK_SEC:-300}; done"] # Healthcheck: index.html exists AND was written within the last (tick * 2)s.
# If render is stuck, container goes unhealthy → operator sees it in
# `docker ps` and `docker compose ps` without parsing logs.
HEALTHCHECK --interval=60s --timeout=10s --start-period=120s --retries=2 \
CMD [ -f /output/index.html ] \
&& [ $(( $(date +%s) - $(stat -c %Y /output/index.html) )) -lt $((BOARD_TICK_SEC * 2)) ] \
|| exit 1
# Failures go to stderr so `docker compose logs` clearly flags them;
# `|| true` prevents the loop from exiting (we want it to keep retrying).
ENTRYPOINT ["/bin/sh", "-c", "while true; do node --experimental-strip-types src/cli.ts \"$BOARD_CONFIG_PATH\" || >&2 echo \"[$(date -Iseconds)] render failed; will retry in ${BOARD_TICK_SEC}s\"; sleep ${BOARD_TICK_SEC}; done"]

View File

@@ -64,6 +64,10 @@ function parseOneBlock(raw: string): StatusBlock | null {
if (header === null) return null; if (header === null) return null;
// **Status:** text field (active/blocked/ready/done/etc.) is parsed into
// `fields` but intentionally not consumed here — the H2 emoji is canonical.
// If STATUS.md drifts (emoji vs text), emoji wins.
return { return {
slug: header.slug, slug: header.slug,
title: header.title, title: header.title,

View File

@@ -32,9 +32,15 @@ export async function readBoard(
if (status === null) continue; if (status === null) continue;
const blocks = parseStatus(status); const blocks = parseStatus(status);
for (const block of blocks) { // Parallel fan-out for per-task commit-iso fetches. Preserves block order
// because Promise.all resolves arrays positionally.
const isoList = await Promise.all(
blocks.map((b) => client.getLatestCommitIso(owner, repo, `.tasks/${b.slug}.md`)),
);
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]!;
const taskPath = `.tasks/${block.slug}.md`; const taskPath = `.tasks/${block.slug}.md`;
const last_commit_iso = await client.getLatestCommitIso(owner, repo, taskPath);
records.push({ records.push({
slug: block.slug, slug: block.slug,
project: repo, project: repo,
@@ -46,7 +52,7 @@ export async function readBoard(
next_action: block.next_action, next_action: block.next_action,
blocker: block.blocker, blocker: block.blocker,
branch: block.branch, branch: block.branch,
last_commit_iso, last_commit_iso: isoList[i]!,
raw_url: client.rawUrl(owner, repo, taskPath), raw_url: client.rawUrl(owner, repo, taskPath),
}); });
} }

View File

@@ -28,6 +28,8 @@ const MS_PER_DAY = 86_400_000;
export function formatAge(iso: string | null, now: Date): string | null { export function formatAge(iso: string | null, now: Date): string | null {
if (iso === null) return null; if (iso === null) return null;
const then = new Date(iso); const then = new Date(iso);
// clamps future iso to '0m' — server clock drift between VDS and Gitea
// shouldn't surface as a negative or throw. See test "clamps future …".
const diffMs = Math.max(0, now.getTime() - then.getTime()); const diffMs = Math.max(0, now.getTime() - then.getTime());
const minutes = Math.floor(diffMs / 60_000); const minutes = Math.floor(diffMs / 60_000);
if (minutes < 60) return `${minutes}m`; if (minutes < 60) return `${minutes}m`;
@@ -70,6 +72,11 @@ export function renderBoard(records: ReadonlyArray<TaskRecord>, opts: RenderOpti
const recordsJson = escapeForScript(JSON.stringify(records)); const recordsJson = escapeForScript(JSON.stringify(records));
const generated = now.toISOString(); const generated = now.toISOString();
const statusFilters = COLUMN_ORDER.map(
(c) =>
`<button type="button" class="status-filter" data-status="${attr(c.status)}">${c.emoji} ${escapeHtml(c.label)}</button>`,
).join('\n ');
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -81,11 +88,14 @@ export function renderBoard(records: ReadonlyArray<TaskRecord>, opts: RenderOpti
<header data-generated="${attr(generated)}"> <header data-generated="${attr(generated)}">
<h1>Tasks</h1> <h1>Tasks</h1>
<div class="controls"> <div class="controls">
<input class="filter" type="search" placeholder="filter slug / title / project" /> <input class="filter" type="search" placeholder="filter slug / title / project / owner" />
<label><input type="checkbox" class="toggle-archived"> show archived</label> <label><input type="checkbox" class="toggle-archived"> show archived</label>
<label><input type="checkbox" class="toggle-grouping"> group by project</label> <label><input type="checkbox" class="toggle-grouping"> group by project</label>
<span class="refresh-info"></span> <span class="refresh-info"></span>
</div> </div>
<div class="status-filters">
${statusFilters}
</div>
</header> </header>
<main class="board"> <main class="board">
${columnsHtml} ${columnsHtml}

View File

@@ -81,6 +81,36 @@ header h1 {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.status-filters {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.5rem;
width: 100%;
}
.status-filter {
font: inherit;
font-size: 0.75rem;
padding: 0.2rem 0.55rem;
background: transparent;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--muted);
cursor: pointer;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.status-filter:hover {
border-color: var(--muted);
}
.status-filter.active {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
}
main.board { main.board {
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(14rem, 1fr)); grid-template-columns: repeat(5, minmax(14rem, 1fr));

View File

@@ -142,15 +142,30 @@ function updateRefreshInfo() {
updateRefreshInfo(); updateRefreshInfo();
setInterval(updateRefreshInfo, 30_000); setInterval(updateRefreshInfo, 30_000);
const recordsBySlug = new Map(records.map((r) => [r.slug, r]));
const activeStatuses = new Set();
function applyFilter(query) { function applyFilter(query) {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
document.querySelectorAll('.card').forEach((card) => { document.querySelectorAll('.card').forEach((card) => {
const slug = card.dataset.slug || ''; const slug = card.dataset.slug || '';
const project = card.querySelector('.badge.project')?.textContent || ''; const rec = recordsBySlug.get(slug);
const status = card.querySelector('.badge.status')?.textContent || ''; if (!rec) {
const title = card.querySelector('.card-title')?.textContent || ''; card.dataset.hidden = 'true';
const hay = `${slug} ${project} ${status} ${title}`.toLowerCase(); return;
card.dataset.hidden = q === '' || hay.includes(q) ? 'false' : 'true'; }
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';
}); });
} }
@@ -158,6 +173,21 @@ filterInput?.addEventListener('input', (e) => {
applyFilter(e.target.value); 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) => { toggleArchived?.addEventListener('change', (e) => {
document.body.classList.toggle('show-archived', e.target.checked); document.body.classList.toggle('show-archived', e.target.checked);
}); });

View File

@@ -42,6 +42,12 @@ describe('formatAge', () => {
test('returns "0m" for sub-minute differences', () => { test('returns "0m" for sub-minute differences', () => {
expect(formatAge('2026-05-22T11:59:30Z', now)).toBe('0m'); expect(formatAge('2026-05-22T11:59:30Z', now)).toBe('0m');
}); });
test('clamps future timestamps to "0m" (server clock drift safety)', () => {
// ISO in the future of `now` — can happen with clock drift between
// VDS and Gitea. Should clamp to "0m" rather than throw or surface negative.
expect(formatAge('2026-05-22T13:00:00Z', now)).toBe('0m');
});
}); });
describe('partitionByStatus', () => { describe('partitionByStatus', () => {
@@ -184,6 +190,14 @@ describe('renderBoard', () => {
expect(html).not.toMatch(/<span class="date"/); expect(html).not.toMatch(/<span class="date"/);
}); });
test('renders 5 status-filter pill buttons in canonical order', () => {
const html = renderBoard([], { generatedAt: now });
const re = /<button[^>]*class="status-filter"[^>]*data-status="([^"]+)"[^>]*>/g;
const matches = [...html.matchAll(re)].map((m) => m[1]);
expect(matches).toEqual(['open', 'in_progress', 'paused', 'blocked', 'done']);
});
test('writes generation timestamp as data-generated attribute on the header', () => { test('writes generation timestamp as data-generated attribute on the header', () => {
const html = renderBoard([], { generatedAt: now }); const html = renderBoard([], { generatedAt: now });