From fdca0b375aba8901ae547cffb564bf1da7297c9f Mon Sep 17 00:00:00 2001 From: vitya Date: Fri, 22 May 2026 18:51:12 +0300 Subject: [PATCH] feat(viewer): bundle md + slug + conditional owner pill [v0.2.0] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes 3 of 6 UX-round1 fixes in one TDD cycle (49→56 tests). drawer-bundle-md: TaskRecord.md_content fetched in build-time; drawer reads recordsBySlug instead of fetch — 0 runtime hits to git.kzntsv.site/raw (private repos 401'd browser fetches without token). Fallback dl-block when md=null. ux-show-slug: rendered under card-title in both render.ts and board.js renderCard (re-render path). ux-owner-conditional: distinctOwner(records) pre-scan. Single owner → pill omitted on cards, header gets Owner: X. Multi-owner → pill restored, no header summary. Co-Authored-By: Claude Opus 4.7 (1M context) --- .tasks/STATUS.md | 35 ++++++++++----------- package.json | 2 +- src/reader.ts | 18 +++++++---- src/render.ts | 44 +++++++++++++++++++++------ static/board.css | 41 +++++++++++++++++++++++++ static/board.js | 58 +++++++++++++++++++++++++++-------- tests/reader.test.ts | 37 ++++++++++++++++++++++- tests/render.test.ts | 72 +++++++++++++++++++++++++++++++++++++++++--- 8 files changed, 253 insertions(+), 54 deletions(-) diff --git a/.tasks/STATUS.md b/.tasks/STATUS.md index 282d3af..d44ff05 100644 --- a/.tasks/STATUS.md +++ b/.tasks/STATUS.md @@ -66,23 +66,21 @@ _Updated: 2026-05-22_ --- -## ⚪ [board-viewer-drawer-bundle-md] — fix `Failed to fetch` в drawer: bundle md-content в build-time - -**Status:** ready (critical bug) -**Where I stopped:** (not started — diagnose grounded: приватные репо + `fetch(rawUrl, {credentials:'omit'})` без auth → Gitea 401/302→login → TypeError. Решение — bundle per-task .md в build-time в JSON/inline script, drawer читает локально.) -**Next action:** см. `board-viewer-drawer-bundle-md.md`. TDD mode. Сначала reader test: `TaskRecord.md_content?: string`. Затем render embeds JSON-bundle. Затем `static/board.js openDrawer` читает local instead of fetch. E2E smoke в browser-driver: 0 запросов на git.kzntsv.site/raw при клике. +## 🟢 [board-viewer-drawer-bundle-md] — fix `Failed to fetch` в drawer: bundle md-content в build-time +**Status:** done +**Where I stopped:** TaskRecord.md_content bundled в build-time через `client.getFile(.tasks/.md)`. Drawer читает `recordsBySlug.get(slug).md_content` вместо fetch — 0 runtime запросов к git.kzntsv.site/raw. Fallback на status-block dl когда md=null. 3 новых reader-теста + 1 render-тест. +**Next action:** ops redeploy на VDS (см. .admin/[board-viewer-redeploy-ux-round1]). **Branch:** master - + --- -## ⚪ [board-viewer-ux-show-slug] — показать slug на карточке (сейчас только в data-attr) - -**Status:** ready -**Where I stopped:** (not started) -**Next action:** TDD mode. Failing test в `tests/render.test.ts`: ожидать `...` в output. Impl в `src/render.ts` + CSS в `static/board.css`. Smoke: slug читается на каждой карточке. +## 🟢 [board-viewer-ux-show-slug] — показать slug на карточке (сейчас только в data-attr) +**Status:** done +**Where I stopped:** `{slug}` рендерится в обеих ветках (`render.ts` + `static/board.js`). CSS — monospace, dim, под `card-title`. 1 новый render-тест. +**Next action:** ops redeploy. **Branch:** master - + --- @@ -96,13 +94,12 @@ _Updated: 2026-05-22_ --- -## ⚪ [board-viewer-ux-owner-conditional] — скрывать owner-pill когда все таски одного owner'а - -**Status:** ready -**Where I stopped:** (not started — текущий VDS whitelist single-owner `OpeItcLoc03/*` → pill дублируется visual noise. Рендерить условно: single-owner → шапка «Owner: X (N repos)», без pill; multi-owner → pill на карточках.) -**Next action:** TDD mode. Два теста (single/multi owner fixtures). Impl в `src/render.ts`. Связанный ops-audit в `.admin/[board-viewer-redeploy-ux-round1]`: verify `auth.toml board_viewer_repos` (по memory `books` = `victor/books`, не `OpeItcLoc03/books`). +## 🟢 [board-viewer-ux-owner-conditional] — скрывать owner-pill когда все таски одного owner'а +**Status:** done +**Where I stopped:** `distinctOwner(records)` pre-scan: единственный owner → pill убирается со всех карточек, в header добавляется `Owner: X`; 2+ owners → pill возвращается на карточки. Поведение симметрично в `render.ts` и `static/board.js` (через `SOLO_OWNER` constant). 4 новых render-теста (mono / multi / mono-summary / multi-omits-summary). +**Next action:** ops redeploy + verify auth.toml whitelist на VDS. **Branch:** master - + --- @@ -139,7 +136,7 @@ _Updated: 2026-05-22_ ## 🔵 [board-viewer-ux-round1-review] — review umbrella для 6 UX-fixes round1 **Status:** blocked -**Blocker:** board-viewer-drawer-bundle-md, board-viewer-ux-show-slug, board-viewer-ux-full-datetime, board-viewer-ux-owner-conditional, board-viewer-ux-task-numbers, board-viewer-ux-done-cutoff +**Blocker:** board-viewer-ux-full-datetime, board-viewer-ux-task-numbers, board-viewer-ux-done-cutoff (3/6 round1-fixes еще не сделаны — design-first таски) **Where I stopped:** (not started — открывать когда все 6 импл-таск 🟢) **Next action:** Independent review subagent'ом (clean context, не имплементер) — TDD-immutability (тесты не переписаны под impl), output-correctness (smoke на live board.kzntsv.site после redeploy), regression check (49 существующих тестов остаются pass). **Branch:** master diff --git a/package.json b/package.json index b0f12ed..55cce08 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "board-viewer", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "description": "Read-only HTML kanban viewer over Gitea API", diff --git a/src/reader.ts b/src/reader.ts index 54cfd61..84dc141 100644 --- a/src/reader.ts +++ b/src/reader.ts @@ -19,6 +19,7 @@ export interface TaskRecord { branch: string | null; last_commit_iso: string | null; raw_url: string; + md_content: string | null; } export async function readBoard( @@ -32,15 +33,19 @@ export async function readBoard( if (status === null) continue; const blocks = parseStatus(status); - // 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`)), - ); + // Parallel fan-out: per-task .md content + per-task last-commit-iso. + // Both arrays preserve block order via positional Promise.all resolution. + // md_content is bundled in build-time to avoid runtime browser fetches + // against private Gitea repos (browser has no token; raw URLs 401 → drawer "Failed to fetch"). + const taskPaths = blocks.map((b) => `.tasks/${b.slug}.md`); + const [mdList, isoList] = await Promise.all([ + Promise.all(taskPaths.map((p) => client.getFile(owner, repo, p))), + Promise.all(taskPaths.map((p) => client.getLatestCommitIso(owner, repo, p))), + ]); for (let i = 0; i < blocks.length; i++) { const block = blocks[i]!; - const taskPath = `.tasks/${block.slug}.md`; + const taskPath = taskPaths[i]!; records.push({ slug: block.slug, project: repo, @@ -54,6 +59,7 @@ export async function readBoard( branch: block.branch, last_commit_iso: isoList[i]!, raw_url: client.rawUrl(owner, repo, taskPath), + md_content: mdList[i]!, }); } } diff --git a/src/render.ts b/src/render.ts index 6be481f..e709c46 100644 --- a/src/render.ts +++ b/src/render.ts @@ -64,9 +64,10 @@ export function renderBoard(records: ReadonlyArray, opts: RenderOpti const now = opts.generatedAt; const archiveDays = opts.archiveAfterDays ?? DEFAULT_ARCHIVE_DAYS; const columns = partitionByStatus(records); + const soloOwner = distinctOwner(records); const columnsHtml = columns - .map((col) => renderColumn(col, now, archiveDays)) + .map((col) => renderColumn(col, now, archiveDays, soloOwner)) .join('\n'); const recordsJson = escapeForScript(JSON.stringify(records)); @@ -77,6 +78,10 @@ export function renderBoard(records: ReadonlyArray, opts: RenderOpti ``, ).join('\n '); + const ownerSummary = soloOwner + ? `Owner: ${escapeHtml(soloOwner)}` + : ''; + return ` @@ -84,9 +89,10 @@ export function renderBoard(records: ReadonlyArray, opts: RenderOpti Board Viewer - -
+ +

Tasks

+ ${ownerSummary}
@@ -111,9 +117,22 @@ ${columnsHtml} `; } -function renderColumn(col: StatusColumn, now: Date, archiveDays: number): string { +function distinctOwner(records: ReadonlyArray): string | null { + const owners = new Set(); + for (const r of records) { + if (r.project_owner) owners.add(r.project_owner); + } + return owners.size === 1 ? [...owners][0]! : null; +} + +function renderColumn( + col: StatusColumn, + now: Date, + archiveDays: number, + soloOwner: string | null, +): string { const cards = col.records - .map((r) => renderCard(r, now, archiveDays)) + .map((r) => renderCard(r, now, archiveDays, soloOwner)) .join('\n'); return `

${col.emoji} ${escapeHtml(col.label)} ${col.records.length}

@@ -123,18 +142,25 @@ ${cards}
`; } -function renderCard(r: TaskRecord, now: Date, archiveDays: number): string { +function renderCard( + r: TaskRecord, + now: Date, + archiveDays: number, + soloOwner: string | null, +): string { const age = formatAge(r.last_commit_iso, now); const archived = r.status === 'done' && isOlderThanDays(r.last_commit_iso, now, archiveDays); const truncatedTitle = truncate(r.title, TITLE_MAX); - const ownerPill = r.project_owner - ? `${escapeHtml(r.project_owner)}` - : ''; + const ownerPill = + r.project_owner && soloOwner === null + ? `${escapeHtml(r.project_owner)}` + : ''; const dateSpan = r.last_commit_iso ? `${escapeHtml(shortDate(r.last_commit_iso))}` : ''; return `
  • ${escapeHtml(truncatedTitle)}
    + ${escapeHtml(r.slug)}
    ${escapeHtml(r.project)} ${ownerPill} diff --git a/static/board.css b/static/board.css index d1d569e..ab67884 100644 --- a/static/board.css +++ b/static/board.css @@ -287,3 +287,44 @@ aside.drawer[hidden] { color: var(--muted); font-size: 0.7rem; } + +.card-slug { + display: block; + font: 0.7rem/1.3 ui-monospace, SFMono-Regular, Consolas, monospace; + color: var(--muted); + margin-bottom: 0.35rem; + word-break: break-all; + opacity: 0.85; +} + +.owner-summary { + font-size: 0.8rem; + color: var(--muted); + border: 1px solid var(--border); + padding: 0.1rem 0.55rem; + border-radius: 999px; + font-variant-numeric: tabular-nums; +} + +.drawer-empty { + color: var(--muted); + font-style: italic; +} + +.status-block { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.3rem 0.8rem; + font-size: 0.85rem; + margin: 0; +} + +.status-block dt { + color: var(--muted); + font-weight: 500; +} + +.status-block dd { + margin: 0; + word-break: break-word; +} diff --git a/static/board.js b/static/board.js index 0b23061..105a817 100644 --- a/static/board.js +++ b/static/board.js @@ -34,6 +34,12 @@ const STATUS_EMOJI = { const ARCHIVE_DAYS = 14; +const SOLO_OWNER = (() => { + const owners = new Set(); + for (const r of records) if (r.project_owner) owners.add(r.project_owner); + return owners.size === 1 ? [...owners][0] : null; +})(); + function escapeHtml(s) { return String(s) .replace(/&/g, '&') @@ -79,7 +85,7 @@ function renderCard(rec, now, mode) { const primary = mode === 'by-project' ? `${STATUS_EMOJI[rec.status]} ${STATUS_LABEL[rec.status]}` : `${escapeHtml(rec.project)}`; - const ownerPill = rec.project_owner + const ownerPill = rec.project_owner && SOLO_OWNER === null ? `${escapeHtml(rec.project_owner)}` : ''; const dateSpan = rec.last_commit_iso @@ -87,6 +93,7 @@ function renderCard(rec, now, mode) { : ''; return `
  • ${escapeHtml(truncate(rec.title, 80))}
    + ${escapeHtml(rec.slug)}
    ${primary} ${ownerPill} @@ -197,21 +204,46 @@ toggleGrouping?.addEventListener('change', () => { rerender(); }); -async function openDrawer(card) { +function openDrawer(card) { if (!drawer || !drawerContent) return; - const rawUrl = card.dataset.rawUrl; - const slug = card.dataset.slug; - if (!rawUrl) return; - drawerContent.innerHTML = `

    ${escapeHtml(slug || '')}

    loading…

    `; + const slug = card.dataset.slug || ''; + const rec = recordsBySlug.get(slug); drawer.hidden = false; - try { - const res = await fetch(rawUrl, { credentials: 'omit' }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const md = await res.text(); - drawerContent.innerHTML = `

    ${escapeHtml(slug || '')}

    ` + marked.parse(md); - } catch (err) { - drawerContent.innerHTML = `

    ${escapeHtml(slug || '')}

    Failed to load: ${escapeHtml(String(err))}

    `; + if (!rec) { + drawerContent.innerHTML = `

    ${escapeHtml(slug)}

    record not found.

    `; + return; } + const md = rec.md_content; + if (md === null || md === undefined) { + drawerContent.innerHTML = + `

    ${escapeHtml(slug)}

    ` + + `

    No per-task .md file in repo — status block only.

    ` + + renderStatusBlock(rec); + return; + } + drawerContent.innerHTML = `

    ${escapeHtml(slug)}

    ` + marked.parse(md); +} + +function renderStatusBlock(rec) { + const rows = [ + ['Status', `${STATUS_EMOJI[rec.status]} ${STATUS_LABEL[rec.status]}`], + ['Title', rec.title], + ['Where stopped', rec.where_stopped], + ['Next action', rec.next_action], + ['Blocker', rec.blocker], + ['Branch', rec.branch], + ]; + return ( + '
    ' + + rows + .filter(([, v]) => v !== null && v !== undefined && v !== '') + .map( + ([k, v]) => + `
    ${escapeHtml(k)}
    ${escapeHtml(String(v))}
    `, + ) + .join('') + + '
    ' + ); } document.addEventListener('click', (e) => { diff --git a/tests/reader.test.ts b/tests/reader.test.ts index 2e47f76..ba4b0c2 100644 --- a/tests/reader.test.ts +++ b/tests/reader.test.ts @@ -26,7 +26,11 @@ _Updated: 2026-05-22_ describe('readBoard', () => { test('merges parser output with project metadata and Gitea last-commit + raw URL', async () => { const client = fakeClient({ - getFile: vi.fn().mockResolvedValue(STATUS_MD_ONE), + getFile: vi.fn(async (_o: string, _r: string, path: string) => { + if (path === '.tasks/STATUS.md') return STATUS_MD_ONE; + if (path === '.tasks/task-a.md') return '# task-a\n\ndeep context body.'; + return null; + }), getLatestCommitIso: vi.fn().mockResolvedValue('2026-05-22T09:00:00Z'), rawUrl: vi.fn().mockReturnValue( 'https://git.kzntsv.site/OpeItcLoc03/board-viewer/raw/branch/master/.tasks/task-a.md', @@ -50,9 +54,40 @@ describe('readBoard', () => { last_commit_iso: '2026-05-22T09:00:00Z', raw_url: 'https://git.kzntsv.site/OpeItcLoc03/board-viewer/raw/branch/master/.tasks/task-a.md', + md_content: '# task-a\n\ndeep context body.', }); }); + test('per-task .md missing (404) → md_content is null, record still emitted', async () => { + const client = fakeClient({ + getFile: vi.fn(async (_o: string, _r: string, path: string) => { + if (path === '.tasks/STATUS.md') return STATUS_MD_ONE; + return null; + }), + getLatestCommitIso: vi.fn().mockResolvedValue(null), + }); + + const records = await readBoard(client, [{ owner: 'o', repo: 'r' }]); + + expect(records).toHaveLength(1); + expect(records[0]!.md_content).toBeNull(); + }); + + test('fetches per-task .md alongside STATUS.md (.tasks/.md path)', async () => { + const getFile = vi.fn(async (_o: string, _r: string, path: string) => { + if (path === '.tasks/STATUS.md') return STATUS_MD_ONE; + return 'body'; + }); + const client = fakeClient({ + getFile, + getLatestCommitIso: vi.fn().mockResolvedValue(null), + }); + + await readBoard(client, [{ owner: 'o', repo: 'r' }]); + + expect(getFile).toHaveBeenCalledWith('o', 'r', '.tasks/task-a.md'); + }); + test('skips repo silently when STATUS.md is missing (returns no records for that repo)', async () => { const client = fakeClient({ getFile: vi.fn().mockResolvedValue(null), // 404 diff --git a/tests/render.test.ts b/tests/render.test.ts index c09ad5e..763bec4 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -16,6 +16,7 @@ function rec(overrides: Partial): TaskRecord { branch: 'master', last_commit_iso: null, raw_url: 'https://example/raw', + md_content: null, ...overrides, }; } @@ -154,22 +155,83 @@ describe('renderBoard', () => { expect(html).toContain('"embed-test"'); }); - test('renders owner as a pill when project_owner is non-empty', () => { + test('bundles per-task md_content inside records JSON (drawer reads it without fetch)', () => { + const records = [rec({ slug: 'with-md', md_content: '# heading\n\nbody text' })]; + + const html = renderBoard(records, { generatedAt: now }); + + // md is inside the records script tag, JSON-escaped (newlines as \n) + expect(html).toContain('"md_content":"# heading\\n\\nbody text"'); + }); + + test('renders owner as a pill when records span 2+ distinct owners', () => { const html = renderBoard( - [rec({ slug: 'x', project_owner: 'OpeItcLoc03' })], + [ + rec({ slug: 'x', project_owner: 'alice', project: 'a' }), + rec({ slug: 'y', project_owner: 'bob', project: 'b' }), + ], { generatedAt: now }, ); - expect(html).toMatch(/OpeItcLoc03<\/span>/); + expect(html).toMatch(/alice<\/span>/); + expect(html).toMatch(/bob<\/span>/); + }); + + test('omits owner pill on every card when all records share a single owner', () => { + const html = renderBoard( + [ + rec({ slug: 'x', project_owner: 'alice', project: 'a' }), + rec({ slug: 'y', project_owner: 'alice', project: 'b' }), + ], + { generatedAt: now }, + ); + + expect(html).not.toMatch(//); + }); + + test('emits a single-owner header summary when whitelist is mono-owner', () => { + const html = renderBoard( + [ + rec({ slug: 'x', project_owner: 'alice', project: 'a' }), + rec({ slug: 'y', project_owner: 'alice', project: 'b' }), + ], + { generatedAt: now }, + ); + + expect(html).toMatch(/]*>Owner: alice<\/span>/); + }); + + test('omits the owner-summary header when whitelist is multi-owner', () => { + const html = renderBoard( + [ + rec({ slug: 'x', project_owner: 'alice', project: 'a' }), + rec({ slug: 'y', project_owner: 'bob', project: 'b' }), + ], + { generatedAt: now }, + ); + + expect(html).not.toContain('owner-summary'); }); test('omits owner pill when project_owner is empty string', () => { const html = renderBoard( - [rec({ slug: 'x', project_owner: '' })], + [ + rec({ slug: 'x', project_owner: '' }), + rec({ slug: 'y', project_owner: 'bob' }), + ], { generatedAt: now }, ); - expect(html).not.toContain('badge owner'); + expect(html).not.toMatch(/<\/span>/); + }); + + test('renders slug as a visible on every card', () => { + const html = renderBoard( + [rec({ slug: 'board-viewer-pointers', title: 'pointers' })], + { generatedAt: now }, + ); + + expect(html).toMatch(/board-viewer-pointers<\/code>/); }); test('renders last_commit_iso as a short YYYY-MM-DD date span', () => {