From 469ff112ccaceb2be0cdb952a5f2d54328f54e33 Mon Sep 17 00:00:00 2001 From: vitya Date: Fri, 22 May 2026 15:46:47 +0300 Subject: [PATCH] feat(polish): close all 6 review nitpicks in one sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .tasks/STATUS.md | 8 +++--- .tasks/board-viewer-gitea-reader.md | 1 + .tasks/board-viewer-polish.md | 12 ++++----- deploy/Dockerfile.build | 13 +++++++++- src/parser.ts | 4 +++ src/reader.ts | 12 ++++++--- src/render.ts | 12 ++++++++- static/board.css | 30 ++++++++++++++++++++++ static/board.js | 40 +++++++++++++++++++++++++---- tests/render.test.ts | 14 ++++++++++ 10 files changed, 126 insertions(+), 20 deletions(-) diff --git a/.tasks/STATUS.md b/.tasks/STATUS.md index 2d1d578..5a4dc36 100644 --- a/.tasks/STATUS.md +++ b/.tasks/STATUS.md @@ -57,10 +57,10 @@ _Updated: 2026-05-22_ --- -## ⚪ [board-viewer-polish] — bundle of 6 minor/nitpick findings from review -**Status:** ready -**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. -**Next action:** забирать items по одному. См. `board-viewer-polish.md`. +## 🟢 [board-viewer-polish] — bundle of 6 minor/nitpick findings from review +**Status:** done +**Where I stopped:** все 6 items закрыты в одной сессии. 49 тестов pass (+2 от polish: M2 future-ts, M4 status-filter markup). Smoke-verified: status pill click фильтрует cards. +**Next action:** — **Branch:** master --- diff --git a/.tasks/board-viewer-gitea-reader.md b/.tasks/board-viewer-gitea-reader.md index 83724e4..d4b2706 100644 --- a/.tasks/board-viewer-gitea-reader.md +++ b/.tasks/board-viewer-gitea-reader.md @@ -47,6 +47,7 @@ - 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: язык — 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/.md` + `STATUS.md`"). Orphan per-task `.md` файлы без блока в STATUS.md — out of scope reader'а. Если потребуется — отдельная таска `reader-list-orphan-tasks`. ## Open questions diff --git a/.tasks/board-viewer-polish.md b/.tasks/board-viewer-polish.md index 64cc8f2..3b81905 100644 --- a/.tasks/board-viewer-polish.md +++ b/.tasks/board-viewer-polish.md @@ -48,12 +48,12 @@ Bundle of minor / nitpick findings from `board-viewer-review` checkpoint. Each ## Completed steps -- [ ] M1: parser comment -- [ ] M2: formatAge future-ts test + comment -- [ ] M3: decisions-log entry in gitea-reader.md -- [ ] M4: filter rebuild + status multi-select OR acceptance amendment -- [ ] M5: stderr + Docker HEALTHCHECK -- [ ] M6: parallelize commit-iso fetches +- [x] M1: comment в `parser.ts` объясняющий что `**Status:**` text field intentionally ignored (emoji canonical) +- [x] M2: тест clamps future timestamps to "0m" + inline comment в formatAge +- [x] M3: decisions-log entry в gitea-reader.md: orphan per-task без блока в STATUS.md — out of scope by design +- [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 задача видна. +- [x] M5: HEALTHCHECK в Dockerfile.build (index.html exists AND fresher than 2×tick); failures redirected to stderr с timestamp +- [x] M6: `getLatestCommitIso` parallelized via Promise.all per-repo (preserves block order) ## Notes diff --git a/deploy/Dockerfile.build b/deploy/Dockerfile.build index 1bcf9ea..dbeb64e 100644 --- a/deploy/Dockerfile.build +++ b/deploy/Dockerfile.build @@ -10,5 +10,16 @@ COPY src ./src COPY static ./static 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"] diff --git a/src/parser.ts b/src/parser.ts index 76e045d..6cace55 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -64,6 +64,10 @@ function parseOneBlock(raw: string): StatusBlock | 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 { slug: header.slug, title: header.title, diff --git a/src/reader.ts b/src/reader.ts index 7ca89f1..54cfd61 100644 --- a/src/reader.ts +++ b/src/reader.ts @@ -32,9 +32,15 @@ export async function readBoard( if (status === null) continue; 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 last_commit_iso = await client.getLatestCommitIso(owner, repo, taskPath); records.push({ slug: block.slug, project: repo, @@ -46,7 +52,7 @@ export async function readBoard( next_action: block.next_action, blocker: block.blocker, branch: block.branch, - last_commit_iso, + last_commit_iso: isoList[i]!, raw_url: client.rawUrl(owner, repo, taskPath), }); } diff --git a/src/render.ts b/src/render.ts index 11b4798..6be481f 100644 --- a/src/render.ts +++ b/src/render.ts @@ -28,6 +28,8 @@ const MS_PER_DAY = 86_400_000; export function formatAge(iso: string | null, now: Date): string | null { if (iso === null) return null; 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 minutes = Math.floor(diffMs / 60_000); if (minutes < 60) return `${minutes}m`; @@ -70,6 +72,11 @@ export function renderBoard(records: ReadonlyArray, opts: RenderOpti const recordsJson = escapeForScript(JSON.stringify(records)); const generated = now.toISOString(); + const statusFilters = COLUMN_ORDER.map( + (c) => + ``, + ).join('\n '); + return ` @@ -81,11 +88,14 @@ export function renderBoard(records: ReadonlyArray, opts: RenderOpti

Tasks

- +
+
+ ${statusFilters} +
${columnsHtml} diff --git a/static/board.css b/static/board.css index 683665a..d1d569e 100644 --- a/static/board.css +++ b/static/board.css @@ -81,6 +81,36 @@ header h1 { 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 { display: grid; grid-template-columns: repeat(5, minmax(14rem, 1fr)); diff --git a/static/board.js b/static/board.js index 8300652..0b23061 100644 --- a/static/board.js +++ b/static/board.js @@ -142,15 +142,30 @@ function updateRefreshInfo() { updateRefreshInfo(); setInterval(updateRefreshInfo, 30_000); +const recordsBySlug = new Map(records.map((r) => [r.slug, r])); +const activeStatuses = new Set(); + 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'; + const rec = recordsBySlug.get(slug); + if (!rec) { + card.dataset.hidden = 'true'; + return; + } + 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); }); +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) => { document.body.classList.toggle('show-archived', e.target.checked); }); diff --git a/tests/render.test.ts b/tests/render.test.ts index 12f5bb9..c09ad5e 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -42,6 +42,12 @@ describe('formatAge', () => { test('returns "0m" for sub-minute differences', () => { 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', () => { @@ -184,6 +190,14 @@ describe('renderBoard', () => { expect(html).not.toMatch(/ { + const html = renderBoard([], { generatedAt: now }); + + const re = /]*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', () => { const html = renderBoard([], { generatedAt: now });