feat(viewer): bundle md + slug + conditional owner pill [v0.2.0]
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: <code class="card-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 <span class="owner-summary"> Owner: X</span>. Multi-owner → pill restored, no header summary. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -66,23 +66,21 @@ _Updated: 2026-05-22_
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚪ [board-viewer-drawer-bundle-md] — fix `Failed to fetch` в drawer: bundle md-content в build-time
|
## 🟢 [board-viewer-drawer-bundle-md] — fix `Failed to fetch` в drawer: bundle md-content в build-time
|
||||||
|
**Status:** done
|
||||||
**Status:** ready (critical bug)
|
**Where I stopped:** TaskRecord.md_content bundled в build-time через `client.getFile(.tasks/<slug>.md)`. Drawer читает `recordsBySlug.get(slug).md_content` вместо fetch — 0 runtime запросов к git.kzntsv.site/raw. Fallback на status-block dl когда md=null. 3 новых reader-теста + 1 render-тест.
|
||||||
**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:** ops redeploy на VDS (см. .admin/[board-viewer-redeploy-ux-round1]).
|
||||||
**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 при клике.
|
|
||||||
**Branch:** master
|
**Branch:** master
|
||||||
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .workshop / 2026-05-22 / trigger: user-ux-feedback-round1 -->
|
<!-- closed-by: OpeItcLoc03 / 2026-05-22 / note: code-complete (52→56 tests pass, typecheck clean). Live smoke (board.kzntsv.site после redeploy + DevTools network 0 git.kzntsv.site/raw hits) — ops-side. -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚪ [board-viewer-ux-show-slug] — показать slug на карточке (сейчас только в data-attr)
|
## 🟢 [board-viewer-ux-show-slug] — показать slug на карточке (сейчас только в data-attr)
|
||||||
|
**Status:** done
|
||||||
**Status:** ready
|
**Where I stopped:** `<code class="card-slug">{slug}</code>` рендерится в обеих ветках (`render.ts` + `static/board.js`). CSS — monospace, dim, под `card-title`. 1 новый render-тест.
|
||||||
**Where I stopped:** (not started)
|
**Next action:** ops redeploy.
|
||||||
**Next action:** TDD mode. Failing test в `tests/render.test.ts`: ожидать `<code class="card-slug">...</code>` в output. Impl в `src/render.ts` + CSS в `static/board.css`. Smoke: slug читается на каждой карточке.
|
|
||||||
**Branch:** master
|
**Branch:** master
|
||||||
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .workshop / 2026-05-22 / trigger: user-ux-feedback-round1 -->
|
<!-- closed-by: OpeItcLoc03 / 2026-05-22 / note: 56 tests pass, typecheck clean. Visual smoke — после redeploy. -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -96,13 +94,12 @@ _Updated: 2026-05-22_
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚪ [board-viewer-ux-owner-conditional] — скрывать owner-pill когда все таски одного owner'а
|
## 🟢 [board-viewer-ux-owner-conditional] — скрывать owner-pill когда все таски одного owner'а
|
||||||
|
**Status:** done
|
||||||
**Status:** ready
|
**Where I stopped:** `distinctOwner(records)` pre-scan: единственный owner → pill убирается со всех карточек, в header добавляется `<span class="owner-summary">Owner: X</span>`; 2+ owners → pill возвращается на карточки. Поведение симметрично в `render.ts` и `static/board.js` (через `SOLO_OWNER` constant). 4 новых render-теста (mono / multi / mono-summary / multi-omits-summary).
|
||||||
**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:** ops redeploy + verify auth.toml whitelist на VDS.
|
||||||
**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`).
|
|
||||||
**Branch:** master
|
**Branch:** master
|
||||||
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .workshop / 2026-05-22 / trigger: user-ux-feedback-round1 -->
|
<!-- closed-by: OpeItcLoc03 / 2026-05-22 / note: 56 tests pass, typecheck clean. -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -139,7 +136,7 @@ _Updated: 2026-05-22_
|
|||||||
## 🔵 [board-viewer-ux-round1-review] — review umbrella для 6 UX-fixes round1
|
## 🔵 [board-viewer-ux-round1-review] — review umbrella для 6 UX-fixes round1
|
||||||
|
|
||||||
**Status:** blocked
|
**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 импл-таск 🟢)
|
**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).
|
**Next action:** Independent review subagent'ом (clean context, не имплементер) — TDD-immutability (тесты не переписаны под impl), output-correctness (smoke на live board.kzntsv.site после redeploy), regression check (49 существующих тестов остаются pass).
|
||||||
**Branch:** master
|
**Branch:** master
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "board-viewer",
|
"name": "board-viewer",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Read-only HTML kanban viewer over Gitea API",
|
"description": "Read-only HTML kanban viewer over Gitea API",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface TaskRecord {
|
|||||||
branch: string | null;
|
branch: string | null;
|
||||||
last_commit_iso: string | null;
|
last_commit_iso: string | null;
|
||||||
raw_url: string;
|
raw_url: string;
|
||||||
|
md_content: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readBoard(
|
export async function readBoard(
|
||||||
@@ -32,15 +33,19 @@ export async function readBoard(
|
|||||||
if (status === null) continue;
|
if (status === null) continue;
|
||||||
|
|
||||||
const blocks = parseStatus(status);
|
const blocks = parseStatus(status);
|
||||||
// Parallel fan-out for per-task commit-iso fetches. Preserves block order
|
// Parallel fan-out: per-task .md content + per-task last-commit-iso.
|
||||||
// because Promise.all resolves arrays positionally.
|
// Both arrays preserve block order via positional Promise.all resolution.
|
||||||
const isoList = await Promise.all(
|
// md_content is bundled in build-time to avoid runtime browser fetches
|
||||||
blocks.map((b) => client.getLatestCommitIso(owner, repo, `.tasks/${b.slug}.md`)),
|
// 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++) {
|
for (let i = 0; i < blocks.length; i++) {
|
||||||
const block = blocks[i]!;
|
const block = blocks[i]!;
|
||||||
const taskPath = `.tasks/${block.slug}.md`;
|
const taskPath = taskPaths[i]!;
|
||||||
records.push({
|
records.push({
|
||||||
slug: block.slug,
|
slug: block.slug,
|
||||||
project: repo,
|
project: repo,
|
||||||
@@ -54,6 +59,7 @@ export async function readBoard(
|
|||||||
branch: block.branch,
|
branch: block.branch,
|
||||||
last_commit_iso: isoList[i]!,
|
last_commit_iso: isoList[i]!,
|
||||||
raw_url: client.rawUrl(owner, repo, taskPath),
|
raw_url: client.rawUrl(owner, repo, taskPath),
|
||||||
|
md_content: mdList[i]!,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,9 +64,10 @@ export function renderBoard(records: ReadonlyArray<TaskRecord>, opts: RenderOpti
|
|||||||
const now = opts.generatedAt;
|
const now = opts.generatedAt;
|
||||||
const archiveDays = opts.archiveAfterDays ?? DEFAULT_ARCHIVE_DAYS;
|
const archiveDays = opts.archiveAfterDays ?? DEFAULT_ARCHIVE_DAYS;
|
||||||
const columns = partitionByStatus(records);
|
const columns = partitionByStatus(records);
|
||||||
|
const soloOwner = distinctOwner(records);
|
||||||
|
|
||||||
const columnsHtml = columns
|
const columnsHtml = columns
|
||||||
.map((col) => renderColumn(col, now, archiveDays))
|
.map((col) => renderColumn(col, now, archiveDays, soloOwner))
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
const recordsJson = escapeForScript(JSON.stringify(records));
|
const recordsJson = escapeForScript(JSON.stringify(records));
|
||||||
@@ -77,6 +78,10 @@ export function renderBoard(records: ReadonlyArray<TaskRecord>, opts: RenderOpti
|
|||||||
`<button type="button" class="status-filter" data-status="${attr(c.status)}">${c.emoji} ${escapeHtml(c.label)}</button>`,
|
`<button type="button" class="status-filter" data-status="${attr(c.status)}">${c.emoji} ${escapeHtml(c.label)}</button>`,
|
||||||
).join('\n ');
|
).join('\n ');
|
||||||
|
|
||||||
|
const ownerSummary = soloOwner
|
||||||
|
? `<span class="owner-summary" data-owner="${attr(soloOwner)}">Owner: ${escapeHtml(soloOwner)}</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -84,9 +89,10 @@ export function renderBoard(records: ReadonlyArray<TaskRecord>, opts: RenderOpti
|
|||||||
<title>Board Viewer</title>
|
<title>Board Viewer</title>
|
||||||
<link rel="stylesheet" href="static/board.css">
|
<link rel="stylesheet" href="static/board.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body${soloOwner ? ' class="solo-owner"' : ''}>
|
||||||
<header data-generated="${attr(generated)}">
|
<header data-generated="${attr(generated)}"${soloOwner ? ` data-solo-owner="${attr(soloOwner)}"` : ''}>
|
||||||
<h1>Tasks</h1>
|
<h1>Tasks</h1>
|
||||||
|
${ownerSummary}
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<input class="filter" type="search" placeholder="filter slug / title / project / owner" />
|
<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>
|
||||||
@@ -111,9 +117,22 @@ ${columnsHtml}
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderColumn(col: StatusColumn, now: Date, archiveDays: number): string {
|
function distinctOwner(records: ReadonlyArray<TaskRecord>): string | null {
|
||||||
|
const owners = new Set<string>();
|
||||||
|
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
|
const cards = col.records
|
||||||
.map((r) => renderCard(r, now, archiveDays))
|
.map((r) => renderCard(r, now, archiveDays, soloOwner))
|
||||||
.join('\n');
|
.join('\n');
|
||||||
return ` <section class="col" data-status="${attr(col.status)}">
|
return ` <section class="col" data-status="${attr(col.status)}">
|
||||||
<h2>${col.emoji} ${escapeHtml(col.label)} <span class="count">${col.records.length}</span></h2>
|
<h2>${col.emoji} ${escapeHtml(col.label)} <span class="count">${col.records.length}</span></h2>
|
||||||
@@ -123,11 +142,17 @@ ${cards}
|
|||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 age = formatAge(r.last_commit_iso, now);
|
||||||
const archived = r.status === 'done' && isOlderThanDays(r.last_commit_iso, now, archiveDays);
|
const archived = r.status === 'done' && isOlderThanDays(r.last_commit_iso, now, archiveDays);
|
||||||
const truncatedTitle = truncate(r.title, TITLE_MAX);
|
const truncatedTitle = truncate(r.title, TITLE_MAX);
|
||||||
const ownerPill = r.project_owner
|
const ownerPill =
|
||||||
|
r.project_owner && soloOwner === null
|
||||||
? `<span class="badge owner">${escapeHtml(r.project_owner)}</span>`
|
? `<span class="badge owner">${escapeHtml(r.project_owner)}</span>`
|
||||||
: '';
|
: '';
|
||||||
const dateSpan = r.last_commit_iso
|
const dateSpan = r.last_commit_iso
|
||||||
@@ -135,6 +160,7 @@ function renderCard(r: TaskRecord, now: Date, archiveDays: number): string {
|
|||||||
: '';
|
: '';
|
||||||
return ` <li class="card" data-slug="${attr(r.slug)}" data-raw-url="${attr(r.raw_url)}" data-archived="${archived ? 'true' : 'false'}">
|
return ` <li class="card" data-slug="${attr(r.slug)}" data-raw-url="${attr(r.raw_url)}" data-archived="${archived ? 'true' : 'false'}">
|
||||||
<div class="card-title">${escapeHtml(truncatedTitle)}</div>
|
<div class="card-title">${escapeHtml(truncatedTitle)}</div>
|
||||||
|
<code class="card-slug">${escapeHtml(r.slug)}</code>
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
<span class="badge project">${escapeHtml(r.project)}</span>
|
<span class="badge project">${escapeHtml(r.project)}</span>
|
||||||
${ownerPill}
|
${ownerPill}
|
||||||
|
|||||||
@@ -287,3 +287,44 @@ aside.drawer[hidden] {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.7rem;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ const STATUS_EMOJI = {
|
|||||||
|
|
||||||
const ARCHIVE_DAYS = 14;
|
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) {
|
function escapeHtml(s) {
|
||||||
return String(s)
|
return String(s)
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -79,7 +85,7 @@ function renderCard(rec, now, mode) {
|
|||||||
const primary = mode === 'by-project'
|
const primary = mode === 'by-project'
|
||||||
? `<span class="badge status">${STATUS_EMOJI[rec.status]} ${STATUS_LABEL[rec.status]}</span>`
|
? `<span class="badge status">${STATUS_EMOJI[rec.status]} ${STATUS_LABEL[rec.status]}</span>`
|
||||||
: `<span class="badge project">${escapeHtml(rec.project)}</span>`;
|
: `<span class="badge project">${escapeHtml(rec.project)}</span>`;
|
||||||
const ownerPill = rec.project_owner
|
const ownerPill = rec.project_owner && SOLO_OWNER === null
|
||||||
? `<span class="badge owner">${escapeHtml(rec.project_owner)}</span>`
|
? `<span class="badge owner">${escapeHtml(rec.project_owner)}</span>`
|
||||||
: '';
|
: '';
|
||||||
const dateSpan = rec.last_commit_iso
|
const dateSpan = rec.last_commit_iso
|
||||||
@@ -87,6 +93,7 @@ function renderCard(rec, now, mode) {
|
|||||||
: '';
|
: '';
|
||||||
return `<li class="card" data-slug="${escapeHtml(rec.slug)}" data-raw-url="${escapeHtml(rec.raw_url)}" data-archived="${archived ? 'true' : 'false'}">
|
return `<li class="card" data-slug="${escapeHtml(rec.slug)}" data-raw-url="${escapeHtml(rec.raw_url)}" data-archived="${archived ? 'true' : 'false'}">
|
||||||
<div class="card-title">${escapeHtml(truncate(rec.title, 80))}</div>
|
<div class="card-title">${escapeHtml(truncate(rec.title, 80))}</div>
|
||||||
|
<code class="card-slug">${escapeHtml(rec.slug)}</code>
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
${primary}
|
${primary}
|
||||||
${ownerPill}
|
${ownerPill}
|
||||||
@@ -197,21 +204,46 @@ toggleGrouping?.addEventListener('change', () => {
|
|||||||
rerender();
|
rerender();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function openDrawer(card) {
|
function openDrawer(card) {
|
||||||
if (!drawer || !drawerContent) return;
|
if (!drawer || !drawerContent) return;
|
||||||
const rawUrl = card.dataset.rawUrl;
|
const slug = card.dataset.slug || '';
|
||||||
const slug = card.dataset.slug;
|
const rec = recordsBySlug.get(slug);
|
||||||
if (!rawUrl) return;
|
|
||||||
drawerContent.innerHTML = `<h2>${escapeHtml(slug || '')}</h2><p>loading…</p>`;
|
|
||||||
drawer.hidden = false;
|
drawer.hidden = false;
|
||||||
try {
|
if (!rec) {
|
||||||
const res = await fetch(rawUrl, { credentials: 'omit' });
|
drawerContent.innerHTML = `<h2>${escapeHtml(slug)}</h2><p>record not found.</p>`;
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
return;
|
||||||
const md = await res.text();
|
|
||||||
drawerContent.innerHTML = `<h2>${escapeHtml(slug || '')}</h2>` + marked.parse(md);
|
|
||||||
} catch (err) {
|
|
||||||
drawerContent.innerHTML = `<h2>${escapeHtml(slug || '')}</h2><p>Failed to load: ${escapeHtml(String(err))}</p>`;
|
|
||||||
}
|
}
|
||||||
|
const md = rec.md_content;
|
||||||
|
if (md === null || md === undefined) {
|
||||||
|
drawerContent.innerHTML =
|
||||||
|
`<h2>${escapeHtml(slug)}</h2>` +
|
||||||
|
`<p class="drawer-empty">No per-task <code>.md</code> file in repo — status block only.</p>` +
|
||||||
|
renderStatusBlock(rec);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drawerContent.innerHTML = `<h2>${escapeHtml(slug)}</h2>` + 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 (
|
||||||
|
'<dl class="status-block">' +
|
||||||
|
rows
|
||||||
|
.filter(([, v]) => v !== null && v !== undefined && v !== '')
|
||||||
|
.map(
|
||||||
|
([k, v]) =>
|
||||||
|
`<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(String(v))}</dd>`,
|
||||||
|
)
|
||||||
|
.join('') +
|
||||||
|
'</dl>'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ _Updated: 2026-05-22_
|
|||||||
describe('readBoard', () => {
|
describe('readBoard', () => {
|
||||||
test('merges parser output with project metadata and Gitea last-commit + raw URL', async () => {
|
test('merges parser output with project metadata and Gitea last-commit + raw URL', async () => {
|
||||||
const client = fakeClient({
|
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'),
|
getLatestCommitIso: vi.fn().mockResolvedValue('2026-05-22T09:00:00Z'),
|
||||||
rawUrl: vi.fn().mockReturnValue(
|
rawUrl: vi.fn().mockReturnValue(
|
||||||
'https://git.kzntsv.site/OpeItcLoc03/board-viewer/raw/branch/master/.tasks/task-a.md',
|
'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',
|
last_commit_iso: '2026-05-22T09:00:00Z',
|
||||||
raw_url:
|
raw_url:
|
||||||
'https://git.kzntsv.site/OpeItcLoc03/board-viewer/raw/branch/master/.tasks/task-a.md',
|
'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/<slug>.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 () => {
|
test('skips repo silently when STATUS.md is missing (returns no records for that repo)', async () => {
|
||||||
const client = fakeClient({
|
const client = fakeClient({
|
||||||
getFile: vi.fn().mockResolvedValue(null), // 404
|
getFile: vi.fn().mockResolvedValue(null), // 404
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function rec(overrides: Partial<TaskRecord>): TaskRecord {
|
|||||||
branch: 'master',
|
branch: 'master',
|
||||||
last_commit_iso: null,
|
last_commit_iso: null,
|
||||||
raw_url: 'https://example/raw',
|
raw_url: 'https://example/raw',
|
||||||
|
md_content: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -154,22 +155,83 @@ describe('renderBoard', () => {
|
|||||||
expect(html).toContain('"embed-test"');
|
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(
|
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 },
|
{ generatedAt: now },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toMatch(/<span class="badge owner">OpeItcLoc03<\/span>/);
|
expect(html).toMatch(/<span class="badge owner">alice<\/span>/);
|
||||||
|
expect(html).toMatch(/<span class="badge owner">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(/<span class="badge owner">/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(/<span class="owner-summary"[^>]*>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', () => {
|
test('omits owner pill when project_owner is empty string', () => {
|
||||||
const html = renderBoard(
|
const html = renderBoard(
|
||||||
[rec({ slug: 'x', project_owner: '' })],
|
[
|
||||||
|
rec({ slug: 'x', project_owner: '' }),
|
||||||
|
rec({ slug: 'y', project_owner: 'bob' }),
|
||||||
|
],
|
||||||
{ generatedAt: now },
|
{ generatedAt: now },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).not.toContain('badge owner');
|
expect(html).not.toMatch(/<span class="badge owner"><\/span>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders slug as a visible <code class="card-slug"> on every card', () => {
|
||||||
|
const html = renderBoard(
|
||||||
|
[rec({ slug: 'board-viewer-pointers', title: 'pointers' })],
|
||||||
|
{ generatedAt: now },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toMatch(/<code class="card-slug">board-viewer-pointers<\/code>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders last_commit_iso as a short YYYY-MM-DD date span', () => {
|
test('renders last_commit_iso as a short YYYY-MM-DD date span', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user