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): StatusColumn[] { return COLUMN_ORDER.map(({ status, emoji, label }) => ({ status, emoji, label, records: records.filter((r) => r.status === status), })); } export function renderBoard(records: ReadonlyArray, 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 ` Board Viewer

Tasks

${columnsHtml}
`; } function renderColumn(col: StatusColumn, now: Date, archiveDays: number): string { const cards = col.records .map((r) => renderCard(r, now, archiveDays)) .join('\n'); return `

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

    ${cards}
`; } 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 ? `${escapeHtml(r.project_owner)}` : ''; const dateSpan = r.last_commit_iso ? `${escapeHtml(shortDate(r.last_commit_iso))}` : ''; return `
  • ${escapeHtml(truncatedTitle)}
    ${escapeHtml(r.project)} ${ownerPill} ${dateSpan} ${age === null ? '' : `${escapeHtml(age)}`}
  • `; } 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, '''); } 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, '\\u003e') .replace(/&/g, '\\u0026') .split(LS).join('\\u2028') .split(PS).join('\\u2029'); }