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:
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user