Files
board-viewer/src/render.ts
vitya 930e404545 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>
2026-05-22 15:42:59 +03:00

176 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Status, StatusEmoji } from './parser.ts';
import type { TaskRecord } from './reader.ts';
export interface StatusColumn {
status: Status;
emoji: StatusEmoji;
label: string;
records: TaskRecord[];
}
export interface RenderOptions {
generatedAt: Date;
archiveAfterDays?: number;
}
const COLUMN_ORDER: Array<{ status: Status; emoji: StatusEmoji; label: string }> = [
{ status: 'open', emoji: '⚪', label: 'open' },
{ status: 'in_progress', emoji: '🔴', label: 'in-progress' },
{ status: 'paused', emoji: '🟡', label: 'paused' },
{ status: 'blocked', emoji: '🔵', label: 'blocked' },
{ status: 'done', emoji: '🟢', label: 'done' },
];
const TITLE_MAX = 80;
const DEFAULT_ARCHIVE_DAYS = 14;
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);
const diffMs = Math.max(0, now.getTime() - then.getTime());
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(diffMs / 3_600_000);
if (hours < 24) return `${hours}h`;
const days = Math.floor(diffMs / MS_PER_DAY);
if (days < 7) return `${days}d`;
if (days < 30) return `${Math.floor(days / 7)}w`;
const months = calendarMonthsBetween(then, now);
if (months < 12) return `${months}mo`;
return `${Math.floor(months / 12)}y`;
}
function calendarMonthsBetween(from: Date, to: Date): number {
let months =
(to.getUTCFullYear() - from.getUTCFullYear()) * 12 +
(to.getUTCMonth() - from.getUTCMonth());
if (to.getUTCDate() < from.getUTCDate()) months -= 1;
return Math.max(0, months);
}
export function partitionByStatus(records: ReadonlyArray<TaskRecord>): StatusColumn[] {
return COLUMN_ORDER.map(({ status, emoji, label }) => ({
status,
emoji,
label,
records: records.filter((r) => r.status === status),
}));
}
export function renderBoard(records: ReadonlyArray<TaskRecord>, opts: RenderOptions): string {
const now = opts.generatedAt;
const archiveDays = opts.archiveAfterDays ?? DEFAULT_ARCHIVE_DAYS;
const columns = partitionByStatus(records);
const columnsHtml = columns
.map((col) => renderColumn(col, now, archiveDays))
.join('\n');
const recordsJson = escapeForScript(JSON.stringify(records));
const generated = now.toISOString();
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Board Viewer</title>
<link rel="stylesheet" href="static/board.css">
</head>
<body>
<header data-generated="${attr(generated)}">
<h1>Tasks</h1>
<div class="controls">
<input class="filter" type="search" placeholder="filter slug / title / project" />
<label><input type="checkbox" class="toggle-archived"> show archived</label>
<label><input type="checkbox" class="toggle-grouping"> group by project</label>
<span class="refresh-info"></span>
</div>
</header>
<main class="board">
${columnsHtml}
</main>
<aside class="drawer" hidden>
<button class="drawer-close" type="button">×</button>
<div class="drawer-content"></div>
</aside>
<script id="records" type="application/json">${recordsJson}</script>
<script type="module" src="static/board.js"></script>
</body>
</html>
`;
}
function renderColumn(col: StatusColumn, now: Date, archiveDays: number): string {
const cards = col.records
.map((r) => renderCard(r, now, archiveDays))
.join('\n');
return ` <section class="col" data-status="${attr(col.status)}">
<h2>${col.emoji} ${escapeHtml(col.label)} <span class="count">${col.records.length}</span></h2>
<ul class="cards">
${cards}
</ul>
</section>`;
}
function renderCard(r: TaskRecord, now: Date, archiveDays: number): 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
? `<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'}">
<div class="card-title">${escapeHtml(truncatedTitle)}</div>
<div class="card-meta">
<span class="badge project">${escapeHtml(r.project)}</span>
${ownerPill}
${dateSpan}
${age === null ? '' : `<span class="age">${escapeHtml(age)}</span>`}
</div>
</li>`;
}
function shortDate(iso: string): string {
return iso.slice(0, 10);
}
function isOlderThanDays(iso: string | null, now: Date, days: number): boolean {
if (iso === null) return false;
const diffMs = now.getTime() - new Date(iso).getTime();
return diffMs > days * MS_PER_DAY;
}
function truncate(s: string, max: number): string {
if (s.length <= max) return s;
return s.slice(0, max) + '…';
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function attr(s: string): string {
return escapeHtml(s);
}
const LS = String.fromCharCode(0x2028);
const PS = String.fromCharCode(0x2029);
function escapeForScript(json: string): string {
return json
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.split(LS).join('\\u2028')
.split(PS).join('\\u2029');
}