feat(render): card owner pill + short ISO date span

closes board-viewer-card-meta-fix

Review found two acceptance fields missing from cards:
- project_owner was in JSON but never displayed
- last_commit_iso was only consumed for relative age; no absolute date

Now both rendered (server-side src/render.ts + client-side static/board.js
in sync), with 4 new TDD tests:
- renders owner pill when project_owner is set
- omits owner pill when empty
- renders short YYYY-MM-DD date when last_commit_iso is set
- omits date span when null

CSS: .badge.owner = transparent + border (visually distinct from solid
.badge.project), .date = small muted tabular-nums. Smoke-verified in browser.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
vitya
2026-05-22 15:42:59 +03:00
parent 7cebeda219
commit 930e404545
6 changed files with 79 additions and 7 deletions

View File

@@ -49,10 +49,10 @@ _Updated: 2026-05-22_
--- ---
## [board-viewer-card-meta-fix] — add owner pill + last-commit-marker to cards ## 🟢 [board-viewer-card-meta-fix] — add owner pill + last-commit-marker to cards
**Status:** ready **Status:** done
**Where I stopped:** review нашёл acceptance gap — `project_owner` есть в JSON но не выводится; `last_commit_iso` только в relative age, не абсолютной дате. **Where I stopped:** owner pill + short ISO date (`YYYY-MM-DD` + tooltip с full ISO) рендерятся в обеих ветках (`render.ts` + `board.js`). 4 новых TDD-теста; smoke-verified.
**Next action:** TDD render + симметрично client-side board.js. См. `board-viewer-card-meta-fix.md`. **Next action:**
**Branch:** master **Branch:** master
--- ---

View File

@@ -36,7 +36,11 @@ html-render таска закрылась без этих полей — gap. Н
## Completed steps ## Completed steps
- [ ] (фиксируется при выполнении) - [x] RED+GREEN: 4 теста для owner pill + date span (renders/omits)
- [x] `src/render.ts` renderCard: owner pill + date span (short YYYY-MM-DD + tooltip с full ISO)
- [x] `static/board.js` renderCard синхронно
- [x] `static/board.css`: `.badge.owner` (transparent + border) + `.date` (small muted tabular-nums)
- [x] smoke-verified в браузере: top cards показывают `board-viewer` + `OpeItcLoc03` + `2026-05-22` + `25m`
## Notes ## Notes

View File

@@ -117,15 +117,27 @@ function renderCard(r: TaskRecord, now: Date, archiveDays: number): 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
? `<span class="badge owner">${escapeHtml(r.project_owner)}</span>`
: '';
const dateSpan = r.last_commit_iso
? `<span class="date" title="${attr(r.last_commit_iso)}">${escapeHtml(shortDate(r.last_commit_iso))}</span>`
: '';
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>
<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}
${dateSpan}
${age === null ? '' : `<span class="age">${escapeHtml(age)}</span>`} ${age === null ? '' : `<span class="age">${escapeHtml(age)}</span>`}
</div> </div>
</li>`; </li>`;
} }
function shortDate(iso: string): string {
return iso.slice(0, 10);
}
function isOlderThanDays(iso: string | null, now: Date, days: number): boolean { function isOlderThanDays(iso: string | null, now: Date, days: number): boolean {
if (iso === null) return false; if (iso === null) return false;
const diffMs = now.getTime() - new Date(iso).getTime(); const diffMs = now.getTime() - new Date(iso).getTime();

View File

@@ -245,3 +245,15 @@ aside.drawer[hidden] {
.badge.status { .badge.status {
background: var(--badge-bg); background: var(--badge-bg);
} }
.badge.owner {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
}
.date {
font-variant-numeric: tabular-nums;
color: var(--muted);
font-size: 0.7rem;
}

View File

@@ -76,13 +76,21 @@ function truncate(s, max) {
function renderCard(rec, now, mode) { function renderCard(rec, now, mode) {
const age = formatAge(rec.last_commit_iso, now); const age = formatAge(rec.last_commit_iso, now);
const archived = isArchived(rec, now); const archived = isArchived(rec, now);
const meta = 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
? `<span class="badge owner">${escapeHtml(rec.project_owner)}</span>`
: '';
const dateSpan = rec.last_commit_iso
? `<span class="date" title="${escapeHtml(rec.last_commit_iso)}">${escapeHtml(rec.last_commit_iso.slice(0, 10))}</span>`
: '';
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>
<div class="card-meta"> <div class="card-meta">
${meta} ${primary}
${ownerPill}
${dateSpan}
${age === null ? '' : `<span class="age">${escapeHtml(age)}</span>`} ${age === null ? '' : `<span class="age">${escapeHtml(age)}</span>`}
</div> </div>
</li>`; </li>`;

View File

@@ -148,6 +148,42 @@ describe('renderBoard', () => {
expect(html).toContain('"embed-test"'); expect(html).toContain('"embed-test"');
}); });
test('renders owner as a pill when project_owner is non-empty', () => {
const html = renderBoard(
[rec({ slug: 'x', project_owner: 'OpeItcLoc03' })],
{ generatedAt: now },
);
expect(html).toMatch(/<span class="badge owner">OpeItcLoc03<\/span>/);
});
test('omits owner pill when project_owner is empty string', () => {
const html = renderBoard(
[rec({ slug: 'x', project_owner: '' })],
{ generatedAt: now },
);
expect(html).not.toContain('badge owner');
});
test('renders last_commit_iso as a short YYYY-MM-DD date span', () => {
const html = renderBoard(
[rec({ slug: 'x', last_commit_iso: '2026-05-19T08:30:00Z' })],
{ generatedAt: now },
);
expect(html).toMatch(/<span class="date"[^>]*>2026-05-19<\/span>/);
});
test('omits date span when last_commit_iso is null', () => {
const html = renderBoard(
[rec({ slug: 'x', last_commit_iso: null })],
{ generatedAt: now },
);
expect(html).not.toMatch(/<span class="date"/);
});
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 });