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>
176 lines
5.6 KiB
TypeScript
176 lines
5.6 KiB
TypeScript
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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');
|
||
}
|